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"]);
}