Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-cli/tests/grpc_credential_binding.rs

//! **GUN-001, on a real listener.** The control-plane credential binds the
//! method, end to end.
//!
//! The unit tests in `gunnar-grpc` prove each link — the span layer puts the
//! dispatch path in the extensions, the gate compares it with the signature,
//! `gunnar-cli`'s interceptor signs the method the generated client is about to
//! call. This file proves the links are *joined*: a real server, a real
//! `tonic::transport::Channel`, a real `sshsig` over a real key, and a header
//! moved from one call to another the way an on-path attacker moves one.
//!
//! The attack it refuses, in the review's own words: take the header off an
//! `Admin.Health` call, re-send it with `:path = /gunnar.v1.Access/AddKey`, and
//! hold a permanent admin key in the same table git over SSH authenticates
//! against. It needed no key, no account and no credential of its own — only a
//! position on the wire, which every shipped medium variant hands it by binding
//! the control plane in plaintext.

mod common;

use common::Harness;
use gunnar_core::HashKind;
use gunnar_proto::access_client::AccessClient;
use gunnar_proto::admin_client::AdminClient;

const NS: &str = "tester";
const REPO: &str = "repo";

/// One credential, signed for `method`, as a metadata value.
fn credential(harness: &Harness, method: &str) -> String {
    use std::sync::atomic::{AtomicU64, Ordering};
    static SEQ: AtomicU64 = AtomicU64::new(0);
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("after 1970")
        .as_millis() as i64;
    let nonce = format!(
        "gunnar-bind-{}-{:016x}",
        SEQ.fetch_add(1, Ordering::Relaxed),
        now
    );
    gunnar_grpc::sign_credential_for(&harness.private_key, now, &nonce, method)
        .expect("sign a bound credential")
        .to_header_value()
}

fn request<T>(message: T, header: &str) -> tonic::Request<T> {
    let mut request = tonic::Request::new(message);
    request.metadata_mut().insert(
        gunnar_grpc::CREDENTIAL_METADATA_KEY,
        header.parse().expect("an ascii credential"),
    );
    request
}

/// **The relay, refused on the wire.**
///
/// `Admin.Health` succeeds with its own credential; the *same shape* of
/// credential, signed for `Admin.Health`, is refused at `Access.ListKeys` with
/// `UNAUTHENTICATED` — and the refusal says which two methods, so an operator
/// reading it can tell a relay from a client bug.
///
/// Both directions, on one connection: a gate that refused everything would
/// pass the second assertion and fail the first.
#[test]
fn a_credential_cannot_be_moved_from_one_method_to_another() {
    let harness = Harness::start_with_grpc(NS, REPO, HashKind::Sha256);
    let channel = harness
        .runtime
        .block_on(
            tonic::transport::Channel::from_shared(harness.grpc_url())
                .unwrap()
                .connect(),
        )
        .expect("dial the control plane");

    // The call the attacker watches.
    let mut admin = AdminClient::new(channel.clone());
    harness
        .runtime
        .block_on(admin.health(request(
            gunnar_proto::HealthRequest {},
            &credential(&harness, "Admin.Health"),
        )))
        .expect("a credential signed for Admin.Health must serve Admin.Health");

    // The same credential shape, at another method. This is the whole finding.
    let lifted = credential(&harness, "Admin.Health");
    let mut access = AccessClient::new(channel.clone());
    let status = harness
        .runtime
        .block_on(access.list_keys(request(
            gunnar_proto::ListKeysRequest {
                principal_id: String::new(),
            },
            &lifted,
        )))
        .expect_err("a credential signed for Admin.Health was served at Access.ListKeys");
    assert_eq!(status.code(), tonic::Code::Unauthenticated);
    assert!(
        status.message().contains("Admin.Health") && status.message().contains("Access.ListKeys"),
        "the refusal must name both methods; it said {:?}",
        status.message()
    );

    // …and the relay did not spend the nonce. The SAME credential, the one
    // that was just refused at `Access.ListKeys`, still serves the method it
    // was issued for. A refusal that burned it would turn this finding into a
    // denial of service instead of closing it: an attacker who cannot use a
    // captured header could still make the honest caller's own call fail.
    let mut admin = AdminClient::new(channel);
    harness
        .runtime
        .block_on(admin.health(request(gunnar_proto::HealthRequest {}, &lifted)))
        .expect("a refused relay spent the honest caller's nonce");
}

/// A method-blind `gunnar-sshsig-v1` credential, the shape GUN-001 relays.
/// Built here rather than taken from the harness: the harness signs v2.
fn v1_credential(harness: &Harness) -> String {
    use std::sync::atomic::{AtomicU64, Ordering};
    static SEQ: AtomicU64 = AtomicU64::new(0);
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("after 1970")
        .as_millis() as i64;
    let nonce = format!(
        "gunnar-v1-{}-{:016x}",
        SEQ.fetch_add(1, Ordering::Relaxed),
        now
    );
    gunnar_grpc::sign_credential(&harness.private_key, now, &nonce)
        .expect("sign a v1 credential")
        .to_header_value()
}

/// **The shipped listener refuses the method-blind v1 credential** (OWNER
/// 2026-10-10: require_bound ON in 5.3.3; stinky row 2).
///
/// No flag, no option: the default harness is the default server. A v1 header
/// is refused at `Admin.Health` — the call it would be lifted off — and at
/// `Access.AddKey`, the call GUN-001 relays it to; the refusal names the scheme
/// to send instead. `Admin.Health` then SAYS the listener is strict and names
/// the two refusals, so a straggler is identified rather than merely broken.
/// A bound credential still works: the door is not simply shut.
#[test]
fn the_shipped_listener_refuses_a_v1_credential_at_every_door() {
    let harness = Harness::start_with_grpc(NS, REPO, HashKind::Sha256);
    let channel = harness
        .runtime
        .block_on(
            tonic::transport::Channel::from_shared(harness.grpc_url())
                .unwrap()
                .connect(),
        )
        .expect("dial");
    let mut admin = AdminClient::new(channel.clone());
    let mut access = AccessClient::new(channel);

    let status = harness
        .runtime
        .block_on(admin.health(request(
            gunnar_proto::HealthRequest {},
            &v1_credential(&harness),
        )))
        .expect_err("the shipped default served a method-blind credential at Admin.Health");
    assert_eq!(status.code(), tonic::Code::Unauthenticated);
    assert!(
        status.message().contains("gunnar-sshsig-v2"),
        "the refusal must name the scheme a client should send; it said {:?}",
        status.message()
    );

    let status = harness
        .runtime
        .block_on(access.add_key(request(
            gunnar_proto::AddKeyRequest::default(),
            &v1_credential(&harness),
        )))
        .expect_err("the shipped default served a method-blind credential at Access.AddKey");
    assert_eq!(status.code(), tonic::Code::Unauthenticated, "{status:?}");

    let health = harness
        .runtime
        .block_on(admin.health(request(
            gunnar_proto::HealthRequest {},
            &credential(&harness, "Admin.Health"),
        )))
        .expect("the shipped listener must serve a method-bound credential")
        .into_inner();
    assert!(
        health.grpc_require_bound_credentials,
        "the listener must SAY it refuses v1; an operator who cannot confirm it is back to guessing"
    );
    assert_eq!(
        health.unbound_credentials_accepted, 0,
        "nothing method-blind may be served"
    );
    assert_eq!(
        health.unbound_credentials_refused, 2,
        "each refusal must be recorded, so the client that broke can be identified"
    );
    let mut methods: Vec<_> = health
        .unbound_credential_callers
        .iter()
        .inspect(|c| {
            assert!(c.refused);
            assert_eq!(c.fingerprint, harness.key_fingerprint);
        })
        .map(|c| c.method.as_str())
        .collect();
    methods.sort_unstable();
    assert_eq!(methods, ["Access.AddKey", "Admin.Health"]);
}