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