crates/gunnar-cli/tests/key_rotation.rs
#![cfg(all(feature = "server", feature = "client", feature = "control"))]
//! **Rotating one machine key on a RUNNING server, without touching its
//! grants.**
//!
//! # The finding
//!
//! MEASURED on the live appliance 2026-09-19. `gunnar-twin`'s box was
//! re-imaged, so the private half of its one machine key was gone and a new key
//! had to take its place. That took six steps and four refusals:
//!
//! ```text
//! gunnar control key revoke <fp> unrecognized subcommand 'key'
//! user add-key gunnar-twin --key … FAILED_PRECONDITION: gunnar-twin already
//! holds 1 key(s), so adding another needs
//! gunnar-twin's own attestation
//! user remove gunnar-twin removed gunnar-twin: 1 keys, 1 grants
//! user add gunnar-twin … ALREADY_EXISTS: namespace already exists
//! namespace delete gunnar-twin 0 repositories went with it
//! user add gunnar-twin … created
//! group member add twin gunnar-twin added
//! ```
//!
//! Every refusal there is individually correct. The attestation precondition is
//! right — an admin who could add a second key to somebody without their word
//! could mint access as them. `key revoke` wanting the server down is right —
//! it writes the control repository directly. `user add` refusing an existing
//! namespace is right. Together they are a path nobody can follow without
//! reading the source, and the third step is the expensive one: `user remove`
//! takes the GRANTS too, so the membership and the access have to be rebuilt
//! from memory afterwards.
//!
//! The missing piece was one verb. `Access.RevokeKey` has been on the wire
//! since the control plane existed and gunnar-ui has called it since the
//! console shipped; the only `Revoke` in the CLI was `gunnar key revoke`, which
//! is OFFLINE — and an appliance is PID 1 with no shell, so on the machine that
//! most needs it, it can never be run.
//!
//! # What this file proves
//!
//! Not that the verb returns OK — that would be the control plane agreeing with
//! itself. What is asserted is the **applied output**: that the old key stops
//! opening a real SSH door, that the new one opens it, and that the grant
//! nobody re-issued is what let it through.
use std::path::Path;
#[path = "common/served_binary.rs"]
mod served_binary;
use served_binary::{gunnar, mint, ssh_reaches, Ran, Serving};
/// The `principal\tfingerprint\talgo` rows of `user keys`, as
/// `(fingerprint, algo)`.
///
/// Read off **stdout alone**: `control.rs` announces the identity it signs with
/// on stderr, and that line carries a `SHA256:` too — the miscount that broke
/// `second_key.rs` on 2026-08-31.
fn keys_of(home: &Path, grpc: &str, principal: &str, ident: &Path) -> Vec<(String, String)> {
let listed = gunnar(
home,
&[
"control",
"user",
"keys",
principal,
"-i",
ident.to_str().unwrap(),
"--at",
grpc,
],
);
assert!(
listed.ok,
"listing {principal}'s keys must succeed: {}",
listed.out
);
listed
.stdout
.lines()
.filter(|line| line.starts_with(&format!("{principal}\t")))
.map(|line| {
let mut columns = line.split('\t');
let _principal = columns.next();
let fingerprint = columns
.next()
.unwrap_or_else(|| panic!("a `user keys` row with no fingerprint column: {line}"));
let algo = columns
.next()
.unwrap_or_else(|| panic!("a `user keys` row with no algo column: {line}"));
(fingerprint.to_owned(), algo.to_owned())
})
.collect()
}
fn revoke_key(home: &Path, grpc: &str, fingerprint: &str, ident: &Path, force: bool) -> Ran {
let mut args = vec![
"control",
"user",
"revoke-key",
fingerprint,
"-i",
ident.to_str().unwrap(),
"--at",
grpc,
];
if force {
args.push("--force");
}
gunnar(home, &args)
}
/// ★ **The 2026-09-19 rotation, in two steps and no refusals — and the grant
/// stays where it was.**
///
/// The transcript above needed `user remove` to get past the attestation
/// precondition, and `user remove` reported *"1 keys, 1 grants"*: the access
/// went with the key. Here the same rotation is `revoke-key` then `add-key`,
/// and the proof that the grant survived is not a listing but the door — the
/// NEW key fetches a repository that only the grant lets it near.
#[test]
fn a_machines_key_rotates_on_a_running_server_and_its_grants_never_move() {
let dir = tempfile::tempdir().expect("a temp dir");
let data_root = dir.path().join("data");
let home = dir.path().join("home");
let keys = home.join("keys");
std::fs::create_dir_all(&keys).expect("mkdir");
let log = dir.path().join("server.log");
let (alice_key, alice_pub) = mint(&keys, "alice");
// The re-imaged box's two halves: the key that died with the old disk, and
// the one the fresh install generated.
let (old_key, old_pub) = mint(&keys, "twin_old");
let (new_key, new_pub) = mint(&keys, "twin_new");
let serving = Serving::start(&data_root, &home, &log);
let grpc = serving.grpc.clone();
let ssh = serving.ssh.clone();
let alice = alice_key.as_path();
let ran = gunnar(
&home,
&[
"control",
"bootstrap",
"alice",
"--key",
&format!("@{}", alice_pub.display()),
"--at",
&grpc,
],
);
assert!(ran.ok, "bootstrap must succeed: {}", ran.out);
let ran = gunnar(
&home,
&[
"control",
"repo",
"create",
"alice/work",
"-i",
alice.to_str().unwrap(),
"--at",
&grpc,
],
);
assert!(ran.ok, "creating alice/work must succeed: {}", ran.out);
// The machine, with its one key — and a grant that is NOT intrinsic, so
// losing it would show.
let ran = gunnar(
&home,
&[
"control",
"user",
"add",
"gunnar-twin",
"--key",
&format!("@{}", old_pub.display()),
"--kind",
"machine",
"-i",
alice.to_str().unwrap(),
"--at",
&grpc,
],
);
assert!(ran.ok, "enrolling gunnar-twin must succeed: {}", ran.out);
let ran = gunnar(
&home,
&[
"control",
"grant",
"gunnar-twin",
"read",
"--repo",
"alice/work",
"-i",
alice.to_str().unwrap(),
"--at",
&grpc,
],
);
assert!(
ran.ok,
"granting gunnar-twin read must succeed: {}",
ran.out
);
// ── RED FIRST, both halves ─────────────────────────────────────────────
//
// The old key opens the door, so "it stops opening it" below is a change
// and not a door that was shut all along.
assert!(
ssh_reaches(&home, &ssh, &old_key, "alice/work").ok,
"the old key must reach alice/work BEFORE the rotation, or nothing after this means \
anything"
);
// And the new key does not, so "it opens it" below is a change too.
assert!(
!ssh_reaches(&home, &ssh, &new_key, "alice/work").ok,
"the new key reached alice/work before it was enrolled"
);
// ── the refusal that made 2026-09-19 six steps long ────────────────────
//
// Asserted, not recited: this is WHY a revoke has to come first, and if the
// precondition ever softened, the argument for this whole file would have
// changed underneath it.
let blocked = gunnar(
&home,
&[
"control",
"user",
"add-key",
"gunnar-twin",
"--key",
&format!("@{}", new_pub.display()),
"-i",
alice.to_str().unwrap(),
"--at",
&grpc,
],
);
assert!(
!blocked.ok && blocked.says("attestation"),
"adding a second key to a principal that already holds one must still need that \
principal's attestation; this file's premise is that refusal:\n{}",
blocked.out
);
// ── step 1: retire the dead key, ONLINE ────────────────────────────────
let listed = keys_of(&home, &grpc, "gunnar-twin", alice);
assert_eq!(
listed.len(),
1,
"gunnar-twin should hold exactly the one key it was enrolled with: {listed:?}"
);
let dead = listed[0].0.clone();
let revoked = revoke_key(&home, &grpc, &dead, alice, false);
assert!(
revoked.ok,
"revoking a machine's key must succeed against a RUNNING server — the whole point of \
the verb:\n{}",
revoked.out
);
assert!(
revoked.stdout.contains("gunnar-twin now holds 0 key(s)"),
"the verb must report the applied set, not echo the request:\n{}",
revoked.stdout
);
// Applied output: the door is shut.
assert!(
!ssh_reaches(&home, &ssh, &old_key, "alice/work").ok,
"the revoked key still reached alice/work over SSH, so nothing was actually revoked"
);
// And the principal is still there, with its namespace — the two things
// `user remove` destroyed on 2026-09-19.
assert!(
keys_of(&home, &grpc, "gunnar-twin", alice).is_empty(),
"gunnar-twin should still exist, holding no keys"
);
// ── step 2: enrol the new one, with NO attestation ─────────────────────
//
// The precondition is gone because it was never about admins: it is about
// asking a principal that has somebody to ask. A principal holding no key
// has nobody.
let added = gunnar(
&home,
&[
"control",
"user",
"add-key",
"gunnar-twin",
"--key",
&format!("@{}", new_pub.display()),
"-i",
alice.to_str().unwrap(),
"--at",
&grpc,
],
);
assert!(
added.ok,
"add-key must succeed once the principal holds no key, which is what makes the rotation \
two steps instead of six:\n{}",
added.out
);
// ── the claim in the test's name ───────────────────────────────────────
//
// Nobody re-issued the grant. If `revoke-key` had taken it the way
// `user remove` does, this is where it would show.
assert!(
ssh_reaches(&home, &ssh, &new_key, "alice/work").ok,
"the new key could not fetch alice/work, so the grant did not survive the rotation — \
which is exactly the cost `user remove` charged on 2026-09-19"
);
let grants = gunnar(
&home,
&[
"control",
"grant",
"list",
"--principal",
"gunnar-twin",
"-i",
alice.to_str().unwrap(),
"--at",
&grpc,
],
);
assert!(
grants.ok && grants.stdout.contains("alice/work"),
"gunnar-twin's repo grant must still be listed after the rotation:\n{}",
grants.out
);
}
/// ★ **A fingerprint that is not on the server revokes nothing, and says so
/// before the wire.**
///
/// The argument this verb takes is a fingerprint typed or pasted by hand. A
/// typo that came back as a bare `NOT_FOUND` from the far side of a tunnel
/// reads like a server problem; it is a keyboard problem, and it names the verb
/// that lists the right answers.
#[test]
fn an_unknown_fingerprint_revokes_nothing_and_points_at_the_listing() {
let dir = tempfile::tempdir().expect("a temp dir");
let data_root = dir.path().join("data");
let home = dir.path().join("home");
let keys = home.join("keys");
std::fs::create_dir_all(&keys).expect("mkdir");
let log = dir.path().join("server.log");
let (alice_key, alice_pub) = mint(&keys, "alice");
let serving = Serving::start(&data_root, &home, &log);
let grpc = serving.grpc.clone();
let ran = gunnar(
&home,
&[
"control",
"bootstrap",
"alice",
"--key",
&format!("@{}", alice_pub.display()),
"--at",
&grpc,
],
);
assert!(ran.ok, "bootstrap must succeed: {}", ran.out);
let before = keys_of(&home, &grpc, "alice", &alice_key);
let missed = revoke_key(
&home,
&grpc,
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
&alice_key,
false,
);
assert!(
!missed.ok,
"an unknown fingerprint must fail:\n{}",
missed.out
);
assert!(
missed.says("nothing was revoked") && missed.says("user keys"),
"the refusal must say nothing happened and name the verb that lists the real \
fingerprints:\n{}",
missed.out
);
assert_eq!(
before,
keys_of(&home, &grpc, "alice", &alice_key),
"a refused revoke must leave the key table exactly as it was"
);
}
/// ★ **The last gunnar-admin key is refused, because an appliance that loses
/// it cannot be given another.**
///
/// gunnar-ui refuses the final enabled admin passkey for the same reason
/// (`enabled_admins <= 1`). It is sharper here: the verb that could re-key a
/// server with no admin key is `gunnar key add`, which writes the control
/// repository directly and so needs the server stopped — and the appliance this
/// verb was written for is PID 1 with no shell. There is no way back.
///
/// Three claims in one test, because they are one behaviour:
///
/// 1. the guard does NOT fire while another admin credential remains — alice
/// holds an SSH key *and* the X.509 certificate `control bootstrap` mints,
/// and the first revoke goes through;
/// 2. it fires on the one that is left, naming it;
/// 3. `--force` is the way past it, and what is on the other side is the
/// lockout the refusal described: the same key is then a stranger — and the
/// verb still reports the revoke it applied, although it can no longer read
/// the key table back with the credential it just destroyed.
#[test]
fn the_last_gunnar_admin_key_is_refused_until_force_says_the_box_is_being_reimaged() {
let dir = tempfile::tempdir().expect("a temp dir");
let data_root = dir.path().join("data");
let home = dir.path().join("home");
let keys = home.join("keys");
std::fs::create_dir_all(&keys).expect("mkdir");
let log = dir.path().join("server.log");
let (alice_key, alice_pub) = mint(&keys, "alice");
let serving = Serving::start(&data_root, &home, &log);
let grpc = serving.grpc.clone();
let ran = gunnar(
&home,
&[
"control",
"bootstrap",
"alice",
"--key",
&format!("@{}", alice_pub.display()),
"--at",
&grpc,
],
);
assert!(ran.ok, "bootstrap must succeed: {}", ran.out);
// Alice is the only gunnar-admin, and holds more than one credential:
// `control bootstrap` registers an mTLS certificate beside the SSH key
// (`algo` = `x509`, since 2026-08-30). Both are keys that can administer
// the box, so the guard must not fire on the first of them.
let held = keys_of(&home, &grpc, "alice", &alice_key);
let ssh_fingerprints: Vec<String> = held
.iter()
.filter(|(_, algo)| algo != "x509")
.map(|(fingerprint, _)| fingerprint.clone())
.collect();
assert_eq!(
ssh_fingerprints.len(),
1,
"alice should hold exactly one SSH key: {held:?}"
);
let last_ssh = ssh_fingerprints[0].clone();
// ── 1. not the last one yet ────────────────────────────────────────────
for (fingerprint, algo) in held.iter().filter(|(_, algo)| algo == "x509") {
let ran = revoke_key(&home, &grpc, fingerprint, &alice_key, false);
assert!(
ran.ok,
"revoking alice's {algo} credential must go through while her SSH key still \
administers the box — a guard that fired here would be counting wrong:\n{}",
ran.out
);
}
// ── 2. now it is ───────────────────────────────────────────────────────
let refused = revoke_key(&home, &grpc, &last_ssh, &alice_key, false);
assert!(
!refused.ok,
"revoking the last gunnar-admin key must be refused:\n{}",
refused.out
);
assert!(
refused.says("LAST gunnar-admin key") && refused.says("--force"),
"the refusal must name what it is protecting and how to override it:\n{}",
refused.out
);
assert!(
keys_of(&home, &grpc, "alice", &alice_key)
.iter()
.any(|(fingerprint, _)| *fingerprint == last_ssh),
"the refused revoke must have left the key in place"
);
// ── 3. --force, and the lockout it warned about ────────────────────────
let forced = revoke_key(&home, &grpc, &last_ssh, &alice_key, true);
// MEASURED while writing this file: the read-back after the revoke is the
// caller reading with the credential it has just destroyed, so it comes
// back `Unauthenticated` — correctly. The verb must still report success,
// because the revoke HAPPENED; an exit code that says otherwise sends an
// operator to retry a thing that is done.
assert!(
forced.ok,
"--force must get past the guard and must not report a revoke it applied as a \
failure:\n{}",
forced.out
);
assert!(
forced.stdout.contains(&format!("revoked {last_ssh}")),
"the applied fact must be printed before anything that can fail:\n{}",
forced.out
);
let after = gunnar(
&home,
&[
"control",
"user",
"list",
"-i",
alice_key.to_str().unwrap(),
"--at",
&grpc,
],
);
assert!(
!after.ok,
"after --force the key must be a stranger to the control plane, which is the lockout \
the refusal was describing; instead it still worked:\n{}",
after.out
);
}