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