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/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(&registered.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."
    );
}