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/issuer_over_the_wire.rs

#![cfg(feature = "server")]
//! **Which CA does this box mint with?** — asked from outside it, and asked of
//! a box that was restored from a twin.
//!
//! # The gap this closes
//!
//! `gunnar issuer show` reads the data root under its feet, and the appliance
//! has no shell: that is the product's whole shape ("gunnar appliance — the
//! forge, no shell"). The issuer certificate went out over exactly one RPC,
//! `Bootstrap.Enroll`, and that window shuts permanently on the first enrol —
//! `install_first_identity` refuses inside its own compare-and-swap. So the
//! certificate every client PINS was unreadable from outside the box, and
//! most unreadable on the box where it matters most: a restored one, where
//! the operator's question is "is this still the CA my tools trust?".
//!
//! `serve.rs` even logs, at first start, that `gunnar issuer show --pem`
//! prints the certificate clients pin — on a machine with nothing to run it
//! on.
//!
//! # What is asserted here
//!
//! 1. the live box answers `Admin.IssuerInfo` with the issuer it actually
//!    mints with, byte for byte the certificate on its disk;
//! 2. **the T1t claim**: a box restored from a twin's transaction log mints
//!    with the SAME issuer as the box it replaced. This is the fact that
//!    matters — a restored box minting under a NEW CA breaks every client that
//!    pinned the old one, silently, at the next certificate it issues — and
//!    until `Admin.IssuerInfo` existed there was nothing to call that could
//!    have caught it.
//!
//! The growbench's T1 twin drill asserts (2) end to end across two real VMs by
//! driving `gunnar issuer show --at` before and after the restore. This file
//! is the same claim over the same code with no hypervisor, so it runs in CI.

mod common;

use std::sync::Arc;

use common::Harness;
use gunnar_cli::server::twin::{restore, Follower, Progress, TwinConfig};
use gunnar_cli::server::ServerStores;
use gunnar_core::HashKind;
use gunnar_proto::admin_client::AdminClient;

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

/// The issuer fingerprint recorded in a data root, read through the handle
/// that cannot sign.
fn issuer_fingerprint(stores: &ServerStores) -> Option<String> {
    stores
        .pki_readonly()
        .expect("open the PKI read-only")
        .issuer()
        .expect("read the issuer")
        .map(|live| live.fingerprint.to_string())
}

/// `Admin.IssuerInfo`, over a real listener with a real credential.
fn over_the_wire(harness: &Harness) -> gunnar_proto::IssuerInfoResponse {
    let channel = harness
        .runtime
        .block_on(
            tonic::transport::Channel::from_shared(harness.grpc_url())
                .unwrap()
                .connect(),
        )
        .expect("dial the control plane");
    let mut admin = AdminClient::new(channel);
    harness
        .runtime
        .block_on(
            admin.issuer_info(
                harness.authed("Admin.IssuerInfo", gunnar_proto::IssuerInfoRequest {}),
            ),
        )
        .expect("Admin.IssuerInfo with a valid credential")
        .into_inner()
}

/// **The box answers, over the wire, with the CA it really mints with.**
///
/// Compared against the document on its own disk rather than against a
/// hard-coded shape: an RPC that returned a plausible empty answer, or one
/// assembled from somewhere other than the PKI document, would pass a
/// self-consistent test and fail this one.
#[test]
fn a_running_box_says_which_ca_it_mints_with() {
    let harness = Harness::start_with_grpc(NS, REPO, HashKind::Sha256);
    let info = over_the_wire(&harness);

    assert!(
        info.has_issuer,
        "the harness server minted an issuer at first start, so the wire read must say so"
    );
    assert_eq!(
        info.fingerprint,
        issuer_fingerprint(&harness.stores).expect("the server minted an issuer"),
        "the wire answer names a different CA than the one on the box's own disk"
    );
    assert_eq!(
        info.cert_pem,
        harness.issuer_pem(),
        "the certificate on the wire is not the certificate a client pins"
    );
    assert_eq!(info.origin, "created", "the harness server minted its own");
    assert!(
        !info.subject.is_empty() && info.created_unix_ms > 0,
        "an issuer with no subject and no adoption date is not a document that was read: {info:?}"
    );

    // The trusted-CA list carries the issuer's own entry, flagged. "Which CAs
    // does this box verify against" is a different question from "which one
    // does it mint with", and an operator mid-rotation is asking the first.
    let issuer_row = info
        .trusted
        .iter()
        .find(|ca| ca.is_issuer)
        .expect("the trusted list flags the live issuer");
    assert_eq!(
        issuer_row.fingerprint, info.fingerprint,
        "the flagged trusted entry is not the live issuer"
    );

    // **The key is not on the wire, and there is no field that could carry
    // it.** The whole response, rendered, must not contain PEM private-key
    // armour — the one thing a read of the PKI must never leak.
    let rendered = format!("{info:?}\n{}", info.render(true));
    for armour in ["PRIVATE KEY", "BEGIN RSA", "BEGIN EC PARAMETERS"] {
        assert!(
            !rendered.contains(armour),
            "Admin.IssuerInfo put {armour} on the wire"
        );
    }
}

/// **T1t: a restored box mints with the SAME issuer as the box it replaced.**
///
/// The failure this exists to catch is quiet. A restored appliance that minted
/// under a fresh CA would serve, authenticate its existing SSH keys, clone
/// every repository — and then hand out certificates no client trusts, at the
/// next `principal add`. Nothing else in the suite looks at the PKI document
/// across a restore.
///
/// The route is the real one, not a file copy: the primary's transaction log
/// into a twin through `Follower`, the twin's log into a fresh data root
/// through `restore`. The PKI document is a `ControlDoc` and rides the same
/// frames every other control document does; the private key is a sealed
/// secret and rides the secret frames. Both have to arrive, or the restored
/// box holds a certificate it cannot sign with — so the assertion is on the
/// fingerprint the WIRE reported for the original, against the document the
/// restored root actually holds.
#[test]
fn a_restored_box_mints_with_the_same_issuer_as_the_box_it_replaced() {
    let harness = Harness::start_with_grpc(NS, REPO, HashKind::Sha256);

    // What the box says about itself, over the control plane — the only way
    // anyone outside a shell-less appliance can learn this.
    let before = over_the_wire(&harness);
    assert!(before.has_issuer, "the primary has an issuer to lose");

    // The twin follows the primary's log.
    let log = Arc::clone(harness.stores.txlog());
    let twin_dir = tempfile::tempdir().unwrap();
    TwinConfig {
        primary: "harness".into(),
        joined_at_unix_ms: 0,
    }
    .save(twin_dir.path())
    .unwrap();
    let twin = Arc::new(ServerStores::open(twin_dir.path()).unwrap());
    let progress = Follower::new(Arc::clone(&twin), Box::new(Arc::clone(&log)))
        .step()
        .unwrap();
    assert!(matches!(progress, Progress::Applied { .. }), "{progress:?}");

    // A re-imaged primary: a data root with nothing in it, restored from the
    // twin's log. This is `gunnar control twin replay` without the VMs.
    let fresh_dir = tempfile::tempdir().unwrap();
    {
        let fresh = Arc::new(ServerStores::open(fresh_dir.path()).unwrap());
        // **The teeth of the assertion below.** A re-imaged box holds NO
        // issuer until something puts one there — `serve` mints one at first
        // start, and a restore must land first or the box comes up under a CA
        // of its own. Asserted rather than assumed: if opening a data root
        // quietly minted one, the comparison at the end of this test could
        // pass by coincidence and prove nothing.
        assert_eq!(
            issuer_fingerprint(&fresh),
            None,
            "a freshly opened data root already holds an issuer, so this test cannot tell a \
             restored CA from a locally minted one"
        );
        // `""` = "the stream names no log id", the same value every other call site in
        // the tree passes (`twin/tests.rs`). T4 added the parameter in `f80c867` and
        // these two gunnar-cli tests were not updated with it, so the crate has not
        // compiled its tests since that merge. Not this lane's change; fixed here
        // because a lane cannot run its own gate past somebody else's build error.
        restore(Arc::clone(twin.txlog()), fresh, "").unwrap();
    }
    let fresh = ServerStores::open(fresh_dir.path()).unwrap();

    assert_eq!(
        issuer_fingerprint(&fresh).as_deref(),
        Some(before.fingerprint.as_str()),
        "the RESTORED box mints under a different CA than the box it replaced — every client \
         that pinned {} is broken, and until Admin.IssuerInfo existed there was no way to ask",
        before.fingerprint
    );
    assert_eq!(
        issuer_fingerprint(&twin).as_deref(),
        Some(before.fingerprint.as_str()),
        "the twin itself carries the primary's issuer; a twin that minted its own would be a \
         box that cannot be promoted"
    );

    // The certificate, not only its fingerprint: a restored box that held the
    // right fingerprint over the wrong bytes would still fail every handshake.
    let restored_pem = fresh
        .pki_readonly()
        .unwrap()
        .issuer()
        .unwrap()
        .unwrap()
        .cert_pem;
    assert_eq!(
        restored_pem, before.cert_pem,
        "the restored issuer certificate is not byte-for-byte the one clients pinned"
    );
}

/// **The two halves of `gunnar issuer show` print the same bytes.**
///
/// `--at` renders the response through `IssuerInfoResponse::render`, and so
/// does the offline half after reading its own data root through
/// `admin::issuer_document`. One renderer over one message: the failure this
/// forecloses is a box that says one thing on its own disk and another over
/// the wire, which is exactly the kind of disagreement nobody would think to
/// check.
#[test]
fn the_wire_and_the_data_root_render_the_same_report() {
    let harness = Harness::start_with_grpc(NS, REPO, HashKind::Sha256);
    let wire = over_the_wire(&harness);
    let disk = gunnar_cli::server::admin::issuer_document(&harness.stores.pki_readonly().unwrap())
        .expect("read the PKI document off the disk");

    assert_eq!(disk.render(false), wire.render(false));
    assert_eq!(disk.render(true), wire.render(true));
    assert!(
        wire.render(true).contains("BEGIN CERTIFICATE"),
        "--pem must print the certificate itself: {}",
        wire.render(true)
    );
    assert!(
        wire.render(false).starts_with("issuer\t"),
        "the report leads with the CA the box mints with: {}",
        wire.render(false)
    );
}