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