crates/gunnar-cli/tests/mtls_handshake.rs
//! **Mutual TLS on the control plane, proven rather than configured.**
//!
//! `gunnar-grpc/src/lib.rs` carried this admission for months:
//!
//! > Until those exist, this listener is **configured** for mTLS and not
//! > **proven** to enforce it.
//!
//! It was worse than that. `Trust::server_tls()` had no production caller at
//! all: the live listener in `serve.rs` served the router over a plain
//! `TcpListenerStream`, so the certificates `gunnar control bootstrap` had been
//! minting and registering since certificate identity landed were inert — nothing could present one and
//! nothing would have checked it.
//!
//! The prescription written in that comment is what this file is. **Three
//! refusals, each asserted before anything is asserted accepted:**
//!
//! 1. no certificate at all;
//! 2. a certificate from a CA that is not this server's;
//! 3. a certificate this server's CA signed, that no principal registered.
//!
//! Only then the acceptance. The order is the whole method: a suite that proves
//! only "a registered certificate works" passes just as green against a
//! listener with no client verification whatsoever, which is exactly the shape
//! that was shipping.
//!
//! (1) and (2) are refused by the **transport** — the handshake never
//! completes, so no RPC is attempted. (3) is refused by the **gate**, after a
//! successful handshake, because the fingerprint is not in the identity key table.
//! Two different mechanisms, and the tests assert the difference: a listener
//! that accepted (3) would be one where any holder of any CA-signed certificate
//! is every principal at once.
mod common;
use common::{Harness, HarnessOptions};
use gunnar_core::{HashKind, Visibility};
use gunnar_proto::admin_client::AdminClient;
use tonic::transport::{Certificate, ClientTlsConfig, Endpoint, Identity};
/// A server whose control plane demands a client certificate.
fn mtls_harness() -> Harness {
// Both halves of every arm below run in **this** process, and this process
// links two rustls providers (russh's aws-lc-rs, tonic's ring). Without the
// pin, the first handshake panics inside the control plane's runtime thread
// and every arm fails as `connection refused` — a symptom that reads like a
// bind failure and sends you looking in the wrong place. That is exactly
// how it presented before `ensure_crypto_provider` existed.
gunnar_grpc::ensure_crypto_provider();
Harness::start_with_options(
"ns",
"repo",
HashKind::Sha1,
HarnessOptions {
grpc: true,
grpc_tls: true,
visibility: Visibility::Private,
..HarnessOptions::default()
},
)
}
/// Try to reach `Admin.Health` over `tls`, and hand back whatever went wrong.
///
/// **The RPC, not merely the connect.** tonic's `Channel::connect` can return
/// before the TLS handshake has been driven to completion, so a test that
/// stopped at connect would call a refused handshake a success. Driving one
/// real request is what forces the handshake to have happened.
fn health_over(harness: &Harness, tls: ClientTlsConfig) -> Result<(), String> {
let endpoint = Endpoint::from_shared(harness.grpc_tls_url())
.expect("a gRPC endpoint")
.tls_config(tls)
.map_err(|err| format!("tls config: {err}"))?;
harness.runtime.block_on(async {
let channel = endpoint
.connect()
.await
.map_err(|err| format!("connect: {err}"))?;
let mut client = AdminClient::new(channel);
client
.health(harness.authed("Admin.Health", gunnar_proto::HealthRequest {}))
.await
.map(|_| ())
.map_err(|status| format!("rpc: {status}"))
})
}
/// A second, unrelated certificate authority and a client certificate under it.
///
/// Built with `rcgen` directly rather than with a second `gunnar_core::Pki`,
/// because the point of the material is that gunnar's issuer has never seen it:
/// a CA minted through gunnar's own PKI would still be gunnar-shaped, and the
/// arm that uses this has to be about provenance, not about shape.
fn foreign_client_identity() -> (String, String) {
let mut ca_params =
rcgen::CertificateParams::new(Vec::<String>::new()).expect("certificate parameters");
ca_params
.distinguished_name
.push(rcgen::DnType::CommonName, "not-gunnars-ca");
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
let ca_key = rcgen::KeyPair::generate().expect("a CA key");
let ca = rcgen::Issuer::new(ca_params, ca_key);
let mut leaf_params =
rcgen::CertificateParams::new(Vec::<String>::new()).expect("certificate parameters");
leaf_params
.distinguished_name
.push(rcgen::DnType::CommonName, "tester");
leaf_params.use_authority_key_identifier_extension = true;
leaf_params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ClientAuth];
let leaf_key = rcgen::KeyPair::generate().expect("a leaf key");
let leaf = leaf_params
.signed_by(&leaf_key, &ca)
.expect("sign the leaf");
(leaf.pem(), leaf_key.serialize_pem())
}
// ── 1. no certificate ───────────────────────────────────────────────────────
/// **A client that presents nothing is refused.**
///
/// Seen red against the listener as it shipped: with no `tls_config` on the
/// server, this connected in plaintext and `Admin.Health` answered `ok`. It is
/// the arm that distinguishes mutual TLS from ordinary TLS, and it is the one a
/// suite that only tests the happy path can never fail.
#[test]
fn a_client_with_no_certificate_cannot_reach_the_control_plane() {
let harness = mtls_harness();
let tls = ClientTlsConfig::new().ca_certificate(Certificate::from_pem(harness.issuer_pem()));
let outcome = health_over(&harness, tls);
assert!(
outcome.is_err(),
"a client that offered no certificate reached Admin.Health on a listener configured for \
mutual TLS; `client_ca_root` is either absent or not enforced, and every certificate \
this server has minted is decoration"
);
}
// ── 2. a certificate from another CA ────────────────────────────────────────
/// **A well-formed certificate from the wrong issuer is refused.**
///
/// The certificate here is valid in every way that does not matter: correct
/// key usage, a `tester` common name that names a real principal on this
/// server, signed by a real CA. It is refused because the CA is not *this*
/// server's, which is the property `client_ca_root` pins and the reason a
/// trust root that accepts anything well-formed is decoration.
#[test]
fn a_certificate_from_another_authority_is_refused() {
let harness = mtls_harness();
let (cert, key) = foreign_client_identity();
let tls = ClientTlsConfig::new()
.ca_certificate(Certificate::from_pem(harness.issuer_pem()))
.identity(Identity::from_pem(cert, key));
let outcome = health_over(&harness, tls);
assert!(
outcome.is_err(),
"a certificate signed by an unrelated CA — with a common name that matches a real \
principal — was accepted; the listener trusts any CA rather than this server's issuer"
);
}
// ── 3. this CA, unregistered ────────────────────────────────────────────────
/// **A certificate this server signed, that nobody registered, is refused —
/// and refused by the gate, not by the transport.**
///
/// This is the arm that says the certificate is a *credential* and not an
/// identity model of its own. The handshake succeeds: the issuer really did
/// sign this leaf. What fails is the identity lookup, because no principal holds this
/// fingerprint — so the answer is `UNAUTHENTICATED` from gunnar's own gate,
/// with an audit record, rather than a TLS alert.
///
/// The distinction is asserted, not assumed. A listener that refused this in
/// the handshake would also pass an `is_err()` check, and would mean something
/// entirely different: that minting a certificate is granting access.
#[test]
fn a_certificate_this_issuer_signed_but_nobody_registered_is_refused_by_the_gate() {
let harness = mtls_harness();
let pki = harness
.stores
.pki()
.expect("the harness stores carry a PKI");
// Minted by the real issuer, and deliberately NOT registered.
let issued = pki
.issue_with_server_key("nobody", gunnar_core::DEFAULT_LEAF_DAYS)
.expect("mint a client certificate");
let key = issued
.key_pem
.clone()
.expect("the issuer minted the key as well");
let tls = ClientTlsConfig::new()
.ca_certificate(Certificate::from_pem(harness.issuer_pem()))
.identity(Identity::from_pem(issued.cert_pem.clone(), key));
// The credential in metadata is dropped for this arm: with one present the
// gate would authenticate the *header* and never look at the connection,
// which is the documented order and would make this test prove nothing.
let endpoint = Endpoint::from_shared(harness.grpc_tls_url())
.expect("a gRPC endpoint")
.tls_config(tls)
.expect("client tls config");
let outcome: Result<(), String> = harness.runtime.block_on(async {
let channel = endpoint
.connect()
.await
.map_err(|err| format!("connect: {err}"))?;
let mut client = AdminClient::new(channel);
client
.health(tonic::Request::new(gunnar_proto::HealthRequest {}))
.await
.map(|_| ())
.map_err(|status| format!("{:?}: {}", status.code(), status.message()))
});
let err = outcome.expect_err(
"a certificate the issuer signed and nobody registered was served; minting a certificate \
is not granting access, and a listener that treats it as such gives every holder of any \
issued certificate the run of the control plane",
);
assert!(
err.contains("Unauthenticated"),
"the refusal did not come from gunnar's gate: {err}. A handshake-level refusal here would \
mean the transport is deciding who a principal is, which is the second identity model \
mutual TLS exists to avoid"
);
assert!(
err.contains("not registered"),
"the refusal does not say the certificate is unregistered: {err}. An operator rolling out \
certificates has to be able to tell `this is the wrong credential` from `this credential \
is right and nobody has been given it`"
);
}
// ── 4. only now, the acceptance ─────────────────────────────────────────────
/// **A registered certificate is served, with no metadata credential at all.**
///
/// Written last on purpose. Everything above must already be red-then-green, or
/// this proves only that a connection can be made.
///
/// No `gunnar-authorization` header is sent: the certificate *is* the
/// credential here, resolved through `Identity::registered_certificate` into
/// the same identity-table principal an SSH key would have named. `tester` holds a
/// server-wide `Read`, which is what `Admin.Health` asks for — so a green
/// answer means the certificate reached the ACL and the ACL answered, rather
/// than that the gate waved it through.
#[test]
fn a_registered_certificate_authenticates_without_any_metadata_credential() {
let harness = mtls_harness();
let pki = harness
.stores
.pki()
.expect("the harness stores carry a PKI");
let issued = pki
.issue_with_server_key("tester", gunnar_core::DEFAULT_LEAF_DAYS)
.expect("mint a client certificate");
let key = issued
.key_pem
.clone()
.expect("the issuer minted the key as well");
// The registration, into the same table the SSH keys live in.
harness
.stores
.auth()
.register_certificate("tester", &issued.cert_der, None)
.expect("register the client certificate");
let endpoint = Endpoint::from_shared(harness.grpc_tls_url())
.expect("a gRPC endpoint")
.tls_config(
ClientTlsConfig::new()
.ca_certificate(Certificate::from_pem(harness.issuer_pem()))
.identity(Identity::from_pem(issued.cert_pem.clone(), key)),
)
.expect("client tls config");
let health = harness.runtime.block_on(async {
let channel = endpoint.connect().await.expect("connect over mutual TLS");
let mut client = AdminClient::new(channel);
client
.health(tonic::Request::new(gunnar_proto::HealthRequest {}))
.await
});
let health = health.expect(
"a certificate minted by this server's issuer and registered to `tester` was refused; the \
loop `gunnar control bootstrap` opens — mint, write, register — still does not close",
);
assert!(
!health.into_inner().version.is_empty(),
"Admin.Health answered without a version, so the handler was never reached"
);
}
/// **Revoking the key row revokes the certificate, in the same instant.**
///
/// The one-table claim, made falsifiable. A certificate is registered, proven
/// to work, then revoked through the same `AuthTable` an SSH key is revoked
/// through — and the very next call over the *same* material is refused. If the
/// certificate path kept its own store, or cached, this would still be served.
#[test]
fn revoking_the_key_row_revokes_the_certificate() {
let harness = mtls_harness();
let pki = harness
.stores
.pki()
.expect("the harness stores carry a PKI");
let issued = pki
.issue_with_server_key("tester", gunnar_core::DEFAULT_LEAF_DAYS)
.expect("mint a client certificate");
let key = issued
.key_pem
.clone()
.expect("the issuer minted the key as well");
let registered = harness
.stores
.auth()
.register_certificate("tester", &issued.cert_der, None)
.expect("register the client certificate");
let call = || -> Result<(), String> {
let endpoint = Endpoint::from_shared(harness.grpc_tls_url())
.expect("a gRPC endpoint")
.tls_config(
ClientTlsConfig::new()
.ca_certificate(Certificate::from_pem(harness.issuer_pem()))
.identity(Identity::from_pem(issued.cert_pem.clone(), key.clone())),
)
.expect("client tls config");
harness.runtime.block_on(async {
let channel = endpoint
.connect()
.await
.map_err(|err| format!("connect: {err}"))?;
AdminClient::new(channel)
.health(tonic::Request::new(gunnar_proto::HealthRequest {}))
.await
.map(|_| ())
.map_err(|status| format!("{:?}: {}", status.code(), status.message()))
})
};
// The control, first: without it "revoked is refused" would pass against a
// certificate that never worked.
call().expect("the registered certificate must be served before revocation means anything");
harness
.stores
.auth()
.revoke_key(
&gunnar_core::Fingerprint::parse(®istered.fingerprint_sha256)
.expect("the fingerprint the table just wrote parses"),
)
.expect("revoke the certificate's key row");
let err = call().expect_err(
"the certificate was still served after its key row was revoked; the certificate path \
reads a different table from the one `gunnar key revoke` writes, and the two can now \
disagree about who may administer this server",
);
assert!(
err.contains("Unauthenticated"),
"the refusal after revocation was not the gate's: {err}"
);
}
// ── 5. the one door that must stay outside, and why TLS cannot be first ─────
/// **`Bootstrap` is behind the handshake too, and that is why
/// `--grpc-tls-self` cannot be the default.**
///
/// `Bootstrap.Status` and `Bootstrap.Enroll` are the two RPCs in
/// `gunnar_grpc::UNAUTHENTICATED_METHODS`: mounted outside the gate, served
/// to a caller with no credential, because on a server that has never been
/// enrolled the identity key table is empty and every credential — including
/// a correct one — fails. Being outside the *gate* is not being outside the
/// *transport*. A mutual listener refuses a peer with no certificate during
/// the handshake, before any router sees a `:path`, so the one door that
/// exists for the caller who holds nothing is shut by the same lock as
/// everything else.
///
/// On an unenrolled server that is a **dead port**: nobody holds a
/// certificate, so nothing completes a handshake, so `Bootstrap.Enroll` — the
/// call that would mint the first one — cannot be reached, and the state is
/// unrecoverable without shell access to the host. That is the evidence
/// behind the startup refusal in `gunnar-server/src/tls_preflight.rs`, and
/// this test is what makes it a measurement rather than an assertion.
///
/// **Both listeners, in one test.** Over plaintext the same call answers, so
/// what is being shown is the difference the trust root makes and not that
/// `Bootstrap.Status` is broken. A one-sided version of this would pass
/// against a server that had no `Bootstrap` service at all.
#[test]
fn bootstrap_is_unreachable_through_the_mutual_door_and_reachable_without_it() {
use gunnar_proto::bootstrap_client::BootstrapClient;
// ── plaintext: the door is open to a caller holding nothing ──────────
let plain = Harness::start_with_options(
"ns",
"repo",
HashKind::Sha1,
HarnessOptions {
grpc: true,
visibility: Visibility::Private,
..HarnessOptions::default()
},
);
let status = plain.runtime.block_on(async {
let channel = tonic::transport::Channel::from_shared(plain.grpc_url())
.expect("an endpoint")
.connect()
.await
.expect("dial the plaintext control plane");
BootstrapClient::new(channel)
// No credential of any kind: that is the whole point of this
// service.
.status(tonic::Request::new(gunnar_proto::BootstrapStatusRequest {}))
.await
});
assert!(
status.is_ok(),
"Bootstrap.Status must answer an uncredentialled caller over plaintext, or the other \
half of this test would be measuring a service that does not work: {status:?}"
);
// ── mutual: the same call, the same lack of credential, refused by the
// transport before the router sees it ───────────────────────────────
let mutual = mtls_harness();
let endpoint = Endpoint::from_shared(mutual.grpc_tls_url())
.expect("a gRPC endpoint")
// The server's CA is pinned, so the client trusts the listener
// completely. What it does not have is a certificate of its own,
// which is exactly the position every caller is in before enrolment.
.tls_config(
ClientTlsConfig::new().ca_certificate(Certificate::from_pem(mutual.issuer_pem())),
)
.expect("client tls config");
let outcome: Result<(), String> = mutual.runtime.block_on(async {
let channel = endpoint
.connect()
.await
.map_err(|err| format!("connect: {err}"))?;
BootstrapClient::new(channel)
.status(tonic::Request::new(gunnar_proto::BootstrapStatusRequest {}))
.await
.map(|_| ())
.map_err(|status| format!("rpc: {status}"))
});
assert!(
outcome.is_err(),
"Bootstrap.Status was served to a caller with no client certificate on a mutual \
listener. If that were so, the startup refusal in `tls_preflight` is guarding a \
state that cannot happen — but it is not: `client_ca_root` is either absent or not \
enforced, which is the same hole `a_client_with_no_certificate_cannot_reach_the_\
control_plane` pins for the gated services."
);
}