crates/gunnar-core/tests/host_key_and_bootstrap.rs
//! The SSH host key and the admin bootstrap (the secret-store seam). Two questions that
//! are really one: where do secrets live with no home directory.
mod common;
use common::{Fixture, KAT_ED25519, KAT_ED25519_FINGERPRINT, OTHER_ED25519};
use gunnar_core::arrow_log::ArrowLogStore;
use gunnar_core::{
ensure_host_key, mint_ed25519, AuthTable, Bootstrap, Fingerprint, Level, Readiness,
BOOTSTRAP_ADMIN_ID,
};
use gunnar_store::mem::MemSecretStore;
use gunnar_store::{SecretStore, SSH_HOST_KEY};
/// **The requirement**: an appliance that regenerates its host key gives every
/// client a host-key-changed warning. The bytes must be identical after a
/// restart.
#[test]
fn the_host_key_is_byte_identical_across_a_simulated_restart() {
let (store, log) = ArrowLogStore::in_memory(gix_hash::Kind::Sha256).expect("open");
let first = ensure_host_key(&store.secrets()).expect("mint");
let first_bytes = first.private_openssh().to_vec();
let first_fingerprint = first.fingerprint();
drop(store);
// Replay the secret log. This is the restart.
let reopened = ArrowLogStore::open_memory(&log, gix_hash::Kind::Sha256).expect("reopen");
let second = ensure_host_key(&reopened.secrets()).expect("load");
assert_eq!(
second.private_openssh(),
first_bytes.as_slice(),
"the host key changed across a restart; every client would see \
REMOTE HOST IDENTIFICATION HAS CHANGED"
);
assert_eq!(second.fingerprint(), first_fingerprint);
// And a third call on the still-open store is the same again.
assert_eq!(
ensure_host_key(&reopened.secrets())
.expect("load")
.private_openssh(),
first_bytes.as_slice()
);
}
/// The red half of the test above. If minting were deterministic (a fixed
/// seed, a stubbed RNG, an empty buffer) "identical after a restart" would
/// pass without anything being stored at all. It is the identity-value trap,
/// and this is what rules it out.
#[test]
fn two_fresh_mints_differ() {
let a = mint_ed25519().expect("mint");
let b = mint_ed25519().expect("mint");
assert_ne!(a, b, "minting is not producing fresh key material");
assert!(!a.is_empty());
assert!(
a.starts_with(b"-----BEGIN OPENSSH PRIVATE KEY-----"),
"the stored bytes must be OpenSSH's own private-key format, so any tool can read them"
);
}
/// The key that comes back is a usable ed25519 key with a real OpenSSH
/// fingerprint, not an opaque blob that happens to round-trip.
#[test]
fn the_host_key_is_a_usable_ed25519_key() {
let secrets = MemSecretStore::new();
let key = ensure_host_key(&secrets).expect("mint");
assert_eq!(key.private_key().algorithm().as_str(), "ssh-ed25519");
let public = key.public_openssh().expect("encode");
assert!(public.starts_with("ssh-ed25519 "), "{public}");
assert_eq!(
Fingerprint::of_openssh(&public).expect("its own public half parses"),
key.fingerprint(),
"the fingerprint gunnar reports must be the fingerprint of the key it serves"
);
assert!(key.fingerprint().as_str().starts_with("SHA256:"));
// It is stored under the key the spec names, so an operator can find it.
assert!(secrets.get(SSH_HOST_KEY).expect("get").is_some());
// Debug must not leak private key material.
let debug = format!("{key:?}");
assert!(!debug.contains("PRIVATE KEY"), "{debug}");
assert!(debug.contains("SHA256:"), "{debug}");
}
#[test]
fn a_stored_host_key_that_is_not_a_key_is_an_error() {
let secrets = MemSecretStore::new();
secrets
.put(SSH_HOST_KEY, b"this is not a private key")
.expect("put");
assert!(
ensure_host_key(&secrets).is_err(),
"garbage in the secret store must not be silently replaced by a new key — \
that is how a host key gets rotated by accident"
);
}
// ── admin bootstrap ───────────────────────────────────────────────────────
/// **What the old refusal became.**
///
/// Until 2026-08-05 this state was `Readiness::RefuseToServe` and `gunnar
/// serve` bailed out of `Server::start`. That is why gunnar had no first boot
/// at all: the box could not be reached to be given its first key, and the only
/// way in was `gunnar key add` with the server DOWN, which needs a shell, and
/// the appliance has no shell.
///
/// It is now `AwaitingBootstrap`: the server starts and `Bootstrap.Enroll` is
/// served with no credential until somebody uses it.
#[test]
fn an_empty_never_bootstrapped_table_opens_the_enrolment_window() {
let fx = Fixture::new();
assert!(fx.auth.is_empty().expect("read"));
let readiness = fx.auth.prepare_to_serve(None).expect("decide");
assert_eq!(readiness, Readiness::AwaitingBootstrap);
assert!(
readiness.bootstrap_window_open(),
"a server nothing has ever been enrolled on must serve Bootstrap.Enroll"
);
assert!(
!readiness.has_an_identity(),
"and it must not claim anybody can authenticate against it"
);
// The operator-facing sentence names the state rather than leaving it to
// be inferred from a boolean.
assert!(format!("{readiness}").contains("AWAITING BOOTSTRAP"));
assert!(
fx.auth.bootstrap_window_open().expect("read"),
"and the predicate the WRITER reads must agree with the one the status reads"
);
assert!(fx.auth.is_empty().expect("read"), "nothing was installed");
}
/// **The attack, and the whole reason the record is a field beside the identity
/// table rather than `keys.is_empty()`.**
///
/// Enrol, then delete every principal and every key, then ask again. The window
/// must stay shut. Seen red by construction: swap
/// `doc.bootstrap.is_none()` in `bootstrap_window_open` for
/// `doc.keys.is_empty()` and this is the assertion that fires.
#[test]
fn deleting_every_identity_does_not_reopen_the_enrolment_window() {
let fx = Fixture::new();
let enrolled = fx
.auth
.enroll_first_user("alice", KAT_ED25519)
.expect("enrol");
assert_eq!(enrolled.record.account, "alice");
assert!(!fx.auth.bootstrap_window_open().expect("read"));
fx.auth.remove_principal("alice").expect("remove");
assert!(
fx.auth.is_empty().expect("read"),
"the identity table really is empty again"
);
assert_eq!(
fx.auth.principals().expect("read").len(),
0,
"and so is the principal list"
);
assert!(
!fx.auth.bootstrap_window_open().expect("read"),
"deleting every identity must NOT reopen the unauthenticated enrolment window"
);
let again = fx.auth.enroll_first_user("bob", OTHER_ED25519);
assert!(
matches!(again, Err(gunnar_core::Error::AlreadyBootstrapped { .. })),
"a second enrolment after a wipe must be refused as AlreadyBootstrapped, got {again:?}"
);
assert!(
fx.auth.is_empty().expect("read"),
"and it must not have installed anything on the way out"
);
// And the start-up decision names it, so an operator is not left guessing
// why a server nobody can reach also will not enrol.
let readiness = fx.auth.prepare_to_serve(None).expect("decide");
assert!(matches!(readiness, Readiness::SealedAndEmpty { .. }));
assert!(!readiness.bootstrap_window_open());
assert!(!readiness.has_an_identity());
}
/// **A second enrolment on a live server is refused**, and it is refused
/// against the record rather than against the key count.
#[test]
fn a_second_enrolment_is_refused() {
let fx = Fixture::new();
fx.auth
.enroll_first_user("alice", KAT_ED25519)
.expect("first");
let second = fx.auth.enroll_first_user("bob", OTHER_ED25519);
assert!(matches!(
second,
Err(gunnar_core::Error::AlreadyBootstrapped { .. })
));
assert_eq!(fx.auth.keys().expect("keys").len(), 1);
assert_eq!(fx.auth.principals().expect("read").len(), 1);
}
/// **A user is admin of its own account and of nobody else's.**
///
/// Both directions, because a rule that answered `true` for every namespace
/// would pass the first assertion alone and hand every account to everybody.
#[test]
fn a_user_is_intrinsically_admin_of_its_own_account_and_of_no_other() {
let fx = Fixture::new();
fx.auth
.enroll_first_user("alice", KAT_ED25519)
.expect("enrol");
fx.auth
.add_principal(
gunnar_core::Principal::new("bob", "bob", gunnar_core::PrincipalKind::User)
.expect("principal"),
)
.expect("add");
assert!(fx
.auth
.may_namespace("alice", "alice", Level::Admin)
.expect("acl"));
assert!(fx
.auth
.may_namespace("bob", "bob", Level::Admin)
.expect("acl"));
assert!(
!fx.auth
.may_namespace("bob", "alice", Level::Read)
.expect("acl"),
"bob must not even READ alice's account by the account rule"
);
// alice IS gunnar-admin, so he reaches bob's namespace through the
// ordinary Scope::Server grant — not through the account rule. Revoking the
// server grant must take that away, which is what tells the two apart.
// (alice is the BOX's first identity — bootstrap enrolled him — and that
// identity is the estate itself, so the 2026-10-05 shield, which stops a
// person's server grant at another person's account, does not bind him;
// `tests/operator_privacy.rs` has the person it does bind.)
assert!(fx
.auth
.may_namespace("alice", "bob", Level::Admin)
.expect("acl"));
fx.auth
.revoke_grant("alice", &gunnar_core::Scope::Server)
.expect("revoke");
assert!(
!fx.auth
.may_namespace("alice", "bob", Level::Admin)
.expect("acl"),
"without the server grant, alice is admin of his OWN account only"
);
assert!(
fx.auth
.may_namespace("alice", "alice", Level::Admin)
.expect("acl"),
"…and his own account is NOT revocable: it is not a grant"
);
// The unknown principal, and the anonymous one, own nothing.
assert!(!fx
.auth
.may_namespace("nobody", "nobody", Level::Read)
.expect("acl"));
assert!(!fx
.auth
.may_namespace(
gunnar_core::ANONYMOUS_PRINCIPAL_ID,
gunnar_core::ANONYMOUS_PRINCIPAL_ID,
Level::Read
)
.expect("acl"));
}
/// The other direction, which is what makes the test above mean something: the
/// very same call with a key supplied starts.
#[test]
fn an_empty_table_with_an_admin_key_bootstraps_and_serves() {
let fx = Fixture::new();
let readiness = fx.auth.prepare_to_serve(Some(KAT_ED25519)).expect("decide");
assert_eq!(readiness, Readiness::Ready);
// The admin exists, holds the key, and may administer the server, without
// which it could not create the first namespace.
let fp = Fingerprint::parse(KAT_ED25519_FINGERPRINT).expect("well-formed");
let principal = fx
.auth
.principal_by_fingerprint(&fp)
.expect("lookup")
.expect("the bootstrap key authenticates");
assert_eq!(principal.id, BOOTSTRAP_ADMIN_ID);
assert!(fx
.auth
.may_server(BOOTSTRAP_ADMIN_ID, Level::Admin)
.expect("acl"));
assert!(!fx.auth.is_empty().expect("read"));
}
/// Bootstrap is safe to call on every boot: once the table has keys it changes
/// nothing, so a stale key left in a config file cannot re-open the door.
#[test]
fn bootstrap_does_nothing_once_the_table_has_keys() {
let fx = Fixture::new();
assert!(matches!(
fx.auth.bootstrap_admin(KAT_ED25519).expect("bootstrap"),
Bootstrap::Installed(_)
));
assert_eq!(
fx.auth.bootstrap_admin(OTHER_ED25519).expect("second"),
Bootstrap::AlreadyProvisioned
);
assert_eq!(fx.auth.keys().expect("keys").len(), 1);
let stranger = Fingerprint::of_openssh(OTHER_ED25519).expect("key");
assert!(
fx.auth
.principal_by_fingerprint(&stranger)
.expect("lookup")
.is_none(),
"a second bootstrap key must not become a second admin"
);
// A table that already has keys is ready with or without a key supplied.
assert_eq!(
fx.auth.prepare_to_serve(None).expect("decide"),
Readiness::Ready
);
assert_eq!(
fx.auth
.prepare_to_serve(Some(OTHER_ED25519))
.expect("decide"),
Readiness::Ready
);
assert_eq!(fx.auth.keys().expect("keys").len(), 1);
}
#[test]
fn a_bad_admin_key_is_refused_rather_than_installed_as_junk() {
let fx = Fixture::new();
assert!(fx.auth.bootstrap_admin("not a key at all").is_err());
assert!(fx.auth.bootstrap_admin("").is_err());
assert!(fx.auth.is_empty().expect("read"));
assert!(fx.auth.prepare_to_serve(Some("not a key at all")).is_err());
assert!(
fx.auth.bootstrap_window_open().expect("read"),
"a refused key must leave the window exactly as it was, not shut it"
);
}
/// The whole first boot, across a restart, over **both** stores the control
/// plane now has: the identity table in a control database on disk (the certificate-identity plan)
/// and the host key still in the Arrow IPC secret log (that half did not
/// move). Host key stable, admin still admin, and no second bootstrap.
#[test]
fn a_first_boot_and_a_second_boot() {
let dir = tempfile::tempdir().expect("temp dir");
let control_db = dir.path().join("control.redb");
let (store, log) = ArrowLogStore::in_memory(gix_hash::Kind::Sha256).expect("open");
let host_bytes = {
let auth = AuthTable::new(gunnar_core::open_control_store(&control_db).expect("control"))
.expect("auth");
assert_eq!(
auth.prepare_to_serve(None).expect("decide"),
Readiness::AwaitingBootstrap
);
assert_eq!(
auth.prepare_to_serve(Some(KAT_ED25519)).expect("decide"),
Readiness::Ready
);
let key = ensure_host_key(&store.secrets()).expect("host key");
let bytes = key.private_openssh().to_vec();
drop(store);
bytes
};
let reopened = ArrowLogStore::open_memory(&log, gix_hash::Kind::Sha256).expect("reopen");
let auth = AuthTable::new(gunnar_core::open_control_store(&control_db).expect("control"))
.expect("auth");
assert_eq!(
auth.prepare_to_serve(None).expect("decide"),
Readiness::Ready
);
assert_eq!(
ensure_host_key(&reopened.secrets())
.expect("host key")
.private_openssh(),
host_bytes.as_slice()
);
let fp = Fingerprint::parse(KAT_ED25519_FINGERPRINT).expect("well-formed");
assert_eq!(
auth.principal_by_fingerprint(&fp)
.expect("lookup")
.expect("admin survived")
.id,
BOOTSTRAP_ADMIN_ID
);
assert_eq!(auth.counts().expect("counts"), (1, 1, 1));
}