Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

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