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

#![cfg(all(feature = "server", feature = "client", feature = "control"))]

//! **One human, several machines, ONE principal — and it survives a restart.**
//!
//! # The finding
//!
//! MEASURED 2026-08-06, a laptop against the live server: the desktop's ssh
//! key is not the laptop's, and there was **no verb to add a second key to an
//! existing principal while the server was up**. So the same human became two
//! principals, `alice` and `alice-desktop` (`SHA256:Bw3OsBmc…`).
//!
//! That is not a missing convenience. If a principal cannot hold a second key
//! then the ACL models **keys as identities** rather than **principals holding
//! keys**: every grant, every ownership and every revoke fragments per machine,
//! and `gunnar control user remove alice` stops meaning what an operator
//! reads it as.
//!
//! # What the model actually was, read rather than inferred
//!
//! It was **already** principals-hold-keys, and had been all along:
//!
//! * `gunnar-core::auth` stores keys as `fingerprint -> PublicKey {
//!   principal_id, … }`, so several rows may name one principal;
//! * `AuthTable::register_key(principal_id, …)` takes the principal and refuses
//!   only a *duplicate fingerprint*, never a second key;
//! * `AuthTable::keys_of(principal_id)` returns a `Vec`;
//! * `Access.AddKey` has existed on the gRPC surface since the control plane
//!   did, with its own authorisation entry.
//!
//! **So it was not a schema change and not a design question. It was one
//! missing CLI verb.** `gunnar control user add` reaches `AddKey`, but calls
//! `AddPrincipal` first, so it could only ever key a brand-new principal. There
//! was no way to call `AddKey` for somebody who already existed.
//!
//! # What this file proves, and why each step is here
//!
//! A test that only called the new verb and read the response back would be a
//! state round-trip — the control plane agreeing with itself. Every claim below
//! is asserted on the **applied output**, which for a key means *the SSH
//! listener lets that key in as that principal*:
//!
//!  1. **RED FIRST:** before the key is enrolled, the second machine's key is
//!     refused over real SSH. Without this the rest passes on nothing.
//!  2. the verb adds it, and `user keys` shows **both**;
//!  3. the second key **fetches over SSH as `alice`** — the same principal,
//!     inheriting grants nobody repeated;
//!  4. the server is **killed and restarted on the same data root**, and the
//!     second key still works — an ACL that lived only in memory would pass
//!     every step above and fail here;
//!  5. and `remove` still takes the whole human, both keys at once, which is
//!     the property two principals could never have given.

use std::path::Path;

#[path = "common/served_binary.rs"]
mod served_binary;
use served_binary::{gunnar, mint, ssh_reaches, Serving};

/// **The credentials `gunnar control user keys` lists for one principal**, as
/// fingerprints.
///
/// Read rather than assumed, and that is the point. This file asserted the
/// literal number **2** until 2026-08-30 and it has not been 2 since the
/// bootstrap certificate landed: `control bootstrap` mints an X.509 client
/// certificate for the first admin and registers it with
/// `AuthTable::register_certificate` (mutual TLS), so `alice` holds an `x509`
/// row beside the SSH key before this test adds anything. The server was right
/// and the constant was stale — the CLI reported *"alice now holds 3 key(s)"*
/// and the assertion demanded 2.
///
/// A credential kind added later must not break this file again, so nothing
/// below names a total: what is asserted is the **delta** of one add-key call
/// and the presence of the fingerprint it enrolled, which is what the verb
/// actually promises.
///
/// # Read stdout, not both streams (2026-08-31)
///
/// The control below counts the listing two ways and requires the two counts to
/// agree, which is the only thing here that can see a row belonging to somebody
/// else. It counted `SHA256:` over **stdout and stderr merged**, and
/// `control.rs` now announces the identity it signs with —
/// `gunnar: signing as …/alice_laptop (SHA256:…)` — on stderr. That line
/// carries a fingerprint, so it counted as a credential the row filter could
/// not match, and the guard reported the two-principals failure against a
/// server that had listed exactly two rows, both `alice`'s.
///
/// The CLI is right and was right deliberately: the `eprintln!` has "stderr, so
/// nothing that parses the verb's stdout changes shape" written beside it. The
/// fixture was the half that merged the streams. So both counts now read
/// [`Ran::stdout`] — the verb's answer — and the guard keeps its whole sharpness:
/// a row for another principal is printed to stdout by the same `println!` as
/// every other row, and still trips it.
fn keys_of(home: &Path, grpc: &str, principal: &str, ident: &Path) -> Vec<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
    );
    let rows: Vec<String> = listed
        .stdout
        .lines()
        .filter(|l| l.starts_with(&format!("{principal}\t")))
        .map(|l| {
            l.split('\t')
                .nth(1)
                .unwrap_or_else(|| panic!("a `user keys` row with no fingerprint column: {l}"))
                .to_owned()
        })
        .collect();
    assert!(
        !rows.is_empty(),
        "`user keys {principal}` listed nothing at all, so every count taken from it is \
         vacuous:\n{}",
        listed.out
    );
    // Every row is THIS principal's. A second principal would list one each and
    // the totals below would still add up. Both counts read the ANSWER —
    // stdout — for the reason in this function's doc comment.
    assert_eq!(
        rows.len(),
        listed
            .stdout
            .lines()
            .filter(|l| l.contains("SHA256:"))
            .count(),
        "`user keys {principal}` listed a credential that is not {principal}'s, which is the \
         two-principals-per-human failure this whole file exists about:\n{}",
        listed.out
    );
    rows
}

#[test]
fn a_second_key_joins_an_existing_user_and_survives_a_restart() {
    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 (laptop_key, laptop_pub) = mint(&keys, "alice_laptop");
    let (desktop_key, desktop_pub) = mint(&keys, "alice_desktop");

    let mut serving = Serving::start(&data_root, &home, &log);

    // ── the human, enrolled from the first machine ─────────────────────────
    let ran = gunnar(
        &home,
        &[
            "control",
            "bootstrap",
            "alice",
            "--key",
            &format!("@{}", laptop_pub.display()),
            "--at",
            &serving.grpc,
        ],
    );
    assert!(ran.ok, "bootstrap must succeed: {}", ran.out);

    // A repository in alice's own account, which they are admin of
    // intrinsically — so nothing below depends on a grant being written.
    let ran = gunnar(
        &home,
        &[
            "control",
            "repo",
            "create",
            "alice/work",
            "-i",
            laptop_key.to_str().unwrap(),
            "--at",
            &serving.grpc,
        ],
    );
    assert!(ran.ok, "creating alice/work must succeed: {}", ran.out);

    // ── 1. RED FIRST ───────────────────────────────────────────────────────
    //
    // The second machine's key is a stranger until it is enrolled. Every
    // assertion after this one is about that changing, and without this they
    // would all pass against a server that let anybody in.
    let before = ssh_reaches(&home, &serving.ssh, &desktop_key, "alice/work");
    assert!(
        !before.ok,
        "the second machine's key already reached the repository BEFORE it was \
         enrolled, so nothing below proves anything:\n{}",
        before.out
    );
    // …and the first machine's key does, or the fixture is broken rather than
    // the door being shut.
    let control = ssh_reaches(&home, &serving.ssh, &laptop_key, "alice/work");
    assert!(
        control.ok,
        "the enrolled key cannot reach the repository either, so 'refused' above \
         says nothing about the second key:\n{}",
        control.out
    );

    // ── 2. the verb that did not exist ─────────────────────────────────────
    //
    // The set BEFORE, so what follows asserts a delta rather than a constant.
    // See `keys_of`: the bootstrap admin already holds an x509 certificate
    // beside the SSH key, and a hard-coded total goes stale every time gunnar
    // grows a credential kind.
    let before_keys = keys_of(&home, &serving.grpc, "alice", &laptop_key);
    let desktop_fingerprint = gunnar_core::Fingerprint::of_openssh(
        &std::fs::read_to_string(&desktop_pub).expect("read the second machine's public key"),
    )
    .expect("the minted public key fingerprints")
    .as_str()
    .to_owned();
    assert!(
        !before_keys.contains(&desktop_fingerprint),
        "the second machine's key is already enrolled before the verb ran, so the delta below \
         measures nothing: {before_keys:?}"
    );

    let ran = gunnar(
        &home,
        &[
            "control",
            "user",
            "add-key",
            "alice",
            "--key",
            &format!("@{}", desktop_pub.display()),
            "-i",
            laptop_key.to_str().unwrap(),
            "--at",
            &serving.grpc,
        ],
    );
    assert!(
        ran.ok,
        "`gunnar control user add-key` on an existing user must succeed: {}",
        ran.out
    );
    assert!(
        ran.says(&format!("alice now holds {} key(s)", before_keys.len() + 1)),
        "it must report the whole set, not just 'added', and the set is the {} it already had \
         plus this one: {}",
        before_keys.len(),
        ran.out
    );
    // And the number it claims is the number it PRINTS. `add-key` lists the
    // whole set under that line (`control.rs`: one `  {fingerprint}\t{algo}`
    // row per credential), so a count that came from anywhere but the rows —
    // "added one, so say one more than last time" — is caught here rather than
    // by a reader believing the summary line.
    assert_eq!(
        ran.out
            .lines()
            .filter(|line| line.trim_start().starts_with("SHA256:"))
            .count(),
        before_keys.len() + 1,
        "add-key says alice holds {} key(s) and lists a different number of them: {}",
        before_keys.len() + 1,
        ran.out
    );

    let after_keys = keys_of(&home, &serving.grpc, "alice", &desktop_key);
    assert_eq!(
        after_keys.len(),
        before_keys.len() + 1,
        "add-key must add exactly one credential to alice, and they must ALL be ALICE's — a \
         second principal would list one each. Before: {before_keys:?}. After: {after_keys:?}"
    );
    assert!(
        after_keys.contains(&desktop_fingerprint),
        "the key that was added is not the key the second machine holds. Added \
         {desktop_fingerprint}, listed {after_keys:?}"
    );

    // ── 3. applied output: the second key is alice at the SSH door ───────
    let after = ssh_reaches(&home, &serving.ssh, &desktop_key, "alice/work");
    assert!(
        after.ok,
        "the second key was enrolled on alice but the SSH listener does not let \
         it in. The control plane and the listener read one table; if they \
         disagree, the verb wrote to the wrong one:\n{}",
        after.out
    );
    // No grant was ever written for this key. It reached the repository because
    // it is alice, which is the entire property two principals cannot have.

    // ── 4. and it is on DISK, not in that process ──────────────────────────
    serving.stop();
    let mut serving = Serving::start(&data_root, &home, &log);

    let after_restart = ssh_reaches(&home, &serving.ssh, &desktop_key, "alice/work");
    assert!(
        after_restart.ok,
        "the second key stopped working when the server restarted, so it never \
         reached the identity table on disk:\n{}",
        after_restart.out
    );
    assert_eq!(
        keys_of(&home, &serving.grpc, "alice", &desktop_key),
        after_keys,
        "the restart changed alice's credentials, so at least one of them lived only in the \
         process that has just been killed"
    );

    // ── 5. one human, one removal ──────────────────────────────────────────
    //
    // The property the two-principal workaround could never give: taking the
    // human away takes every machine with them, in one call, with no way to
    // leave one behind by forgetting it existed.
    let ran = gunnar(
        &home,
        &[
            "control",
            "user",
            "add",
            "bob",
            "--key",
            &format!("@{}", mint(&keys, "bob").1.display()),
            "-i",
            laptop_key.to_str().unwrap(),
            "--at",
            &serving.grpc,
        ],
    );
    assert!(
        ran.ok,
        "a second user is needed to do the removing: {}",
        ran.out
    );
    let ran = gunnar(
        &home,
        &[
            "control",
            "user",
            "remove",
            "bob",
            "-i",
            desktop_key.to_str().unwrap(),
            "--at",
            &serving.grpc,
        ],
    );
    assert!(
        ran.ok,
        "the SECOND key must carry alice's gunnar-admin, since the grant belongs \
         to alice and not to a key: {}",
        ran.out
    );
    assert!(
        ran.says("removed bob: 1 keys"),
        "removal must report what it took: {}",
        ran.out
    );

    serving.stop();
}