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

//! Secrets are sealed at rest, and the proof is the bytes on disk.
//!
//! # Why every test here reads a file rather than calling `get()`
//!
//! A `put()` followed by a `get()` that agrees proves the codec round-trips. It
//! proves **nothing at all** about whether anything was encrypted — a
//! `SealedSecretStore` whose `seal`/`unseal` were both the identity function
//! passes that test perfectly. That is exactly the identity-value trap, so the
//! load-bearing assertions in this file are made against
//! `secrets.arrow` as it exists on disk.
//!
//! # And why each of those has an explicit red half
//!
//! "the plaintext is not in the file" is the shape of assertion that passes
//! when the search is broken, when the file is empty, and when the store never
//! wrote anything. Every such assertion here is paired with a probe that runs
//! the **same search** over an **unsealed** store and requires it to FIND the
//! plaintext. If the detector is blind, that probe fails and says so.

use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};

use gunnar_core::arrow_log::ArrowLogStore;
use gunnar_core::seal::{is_sealed, kek_path, seal, unseal, Kek, SealedSecretStore, SEAL_MAGIC};
use gunnar_store::{SecretStore, SecretStoreExt, SSH_HOST_KEY};

/// A recognisable plaintext. Long enough that a chance occurrence in Arrow
/// framing is not credible, and shaped like the thing that actually matters.
const PLAINTEXT: &[u8] =
    b"-----BEGIN OPENSSH PRIVATE KEY-----\nZ3VubmFyLXNlY3JldC1uZWVkbGU=\n-----END OPENSSH PRIVATE KEY-----\n";

/// The one detector this file trusts, defined once so the red probes and the
/// green assertions cannot drift apart (one implementation).
///
/// Reads `secrets.arrow` off disk — not a store handle, not a cache — and
/// answers whether `needle` appears anywhere in it.
fn secrets_file_contains(dir: &Path, needle: &[u8]) -> bool {
    let path = dir.join("secrets.arrow");
    let bytes = std::fs::read(&path)
        .unwrap_or_else(|err| panic!("no secret log at {}: {err}", path.display()));
    assert!(
        !bytes.is_empty(),
        "{} is empty, so this detector would report `not found` for everything. \
         An empty file is shaped exactly like a clean result.",
        path.display()
    );
    bytes.windows(needle.len()).any(|window| window == needle)
}

fn store_at(dir: &Path) -> ArrowLogStore {
    ArrowLogStore::open(dir, gix_hash::Kind::Sha1).expect("open the arrow log")
}

// ───────────────────────── the red half, first ───────────────────────────────

/// **RED.** Without sealing, the private key is sitting in `secrets.arrow` in
/// the clear, and this test's job is to prove that the detector used by every
/// green assertion below can see it.
///
/// If this ever starts failing, the greens in this file are worthless and must
/// not be believed: it would mean `secrets_file_contains` cannot find a needle
/// that is definitely there.
#[test]
fn an_unsealed_store_leaves_the_private_key_in_the_file_and_the_detector_finds_it() {
    let dir = tempfile::tempdir().expect("tempdir");
    let store = store_at(dir.path());
    store.secrets().put(SSH_HOST_KEY, PLAINTEXT).expect("put");
    drop(store);

    assert!(
        secrets_file_contains(dir.path(), PLAINTEXT),
        "an UNSEALED secret log did not contain the plaintext it was just handed. \
         The detector is broken, and every `is not in the file` assertion in this \
         file is therefore blind."
    );
    // And the same detector must answer `false` for something genuinely absent,
    // or it is a tautology that reports `true` for any input.
    assert!(
        !secrets_file_contains(dir.path(), b"a-needle-that-was-never-stored-anywhere"),
        "the detector reports a needle that was never written; it is not searching, \
         it is agreeing"
    );
}

// ───────────────────────── the greens it licenses ────────────────────────────

/// The whole point: with sealing on, the private key is **not** in the file.
#[test]
fn a_sealed_store_puts_no_plaintext_on_disk() {
    let dir = tempfile::tempdir().expect("tempdir");
    let sealed = SealedSecretStore::new(store_at(dir.path()).secrets(), Kek::generate().unwrap());
    sealed.put(SSH_HOST_KEY, PLAINTEXT).expect("put");

    assert!(
        !secrets_file_contains(dir.path(), PLAINTEXT),
        "the SEALED secret log contains the plaintext private key. Sealing is not \
         happening, or is happening after the write."
    );
    // Not just the whole key: no recognisable fragment of it either. A cipher
    // that leaked half its input would still pass the assertion above.
    assert!(
        !secrets_file_contains(dir.path(), b"OPENSSH PRIVATE KEY"),
        "a fragment of the private key survived into the sealed log"
    );
    // Positive evidence that *something* was written and it is ciphertext, so
    // this test cannot pass by the store having written nothing at all.
    assert!(
        secrets_file_contains(dir.path(), SEAL_MAGIC),
        "the sealed log carries no `{}` magic, so nothing sealed was written",
        String::from_utf8_lossy(SEAL_MAGIC)
    );
    assert_eq!(
        sealed.get(SSH_HOST_KEY).expect("get").as_deref(),
        Some(PLAINTEXT),
        "the sealed store cannot read back what it wrote"
    );
}

/// The same, through the real host-key path rather than a hand-made `put`.
///
/// `ensure_host_key` mints an OpenSSH ed25519 key; its armour header must not
/// reach the disk, and the key must still be byte-identical after a restart
/// (otherwise every client gets `REMOTE HOST IDENTIFICATION HAS CHANGED`).
#[test]
fn the_real_host_key_is_sealed_and_still_survives_a_restart() {
    let dir = tempfile::tempdir().expect("tempdir");
    let kek = Kek::generate().unwrap();

    let first = {
        let sealed = SealedSecretStore::new(store_at(dir.path()).secrets(), kek.clone());
        gunnar_core::ensure_host_key(&sealed).expect("mint")
    };

    assert!(
        !secrets_file_contains(dir.path(), b"BEGIN OPENSSH PRIVATE KEY"),
        "the OpenSSH private-key armour is on disk in the clear"
    );
    assert!(
        !secrets_file_contains(dir.path(), first.private_openssh()),
        "the minted host key's exact bytes are on disk in the clear"
    );

    let second = {
        let sealed = SealedSecretStore::new(store_at(dir.path()).secrets(), kek.clone());
        gunnar_core::ensure_host_key(&sealed).expect("load")
    };
    assert_eq!(
        first.private_openssh(),
        second.private_openssh(),
        "sealing changed the host key across a restart"
    );
    assert_eq!(
        first.fingerprint().as_str(),
        second.fingerprint().as_str(),
        "the fingerprint an operator pins moved across a restart"
    );

    // The negative that gives the equality above its meaning: two independent
    // mints differ, so `first == second` is not the identity value.
    let elsewhere = tempfile::tempdir().expect("tempdir");
    let other = {
        let sealed = SealedSecretStore::new(
            store_at(elsewhere.path()).secrets(),
            Kek::generate().unwrap(),
        );
        gunnar_core::ensure_host_key(&sealed).expect("mint")
    };
    assert_ne!(
        first.private_openssh(),
        other.private_openssh(),
        "two separate stores minted the same host key, so `identical after a restart` \
         proves nothing"
    );
}

/// A KEK that is not the KEK does not open the secret.
///
/// This is what makes the archive-exfiltration claim in `seal.rs` true rather
/// than asserted: the ciphertext alone is not enough.
#[test]
fn the_wrong_kek_cannot_open_a_sealed_secret() {
    let dir = tempfile::tempdir().expect("tempdir");
    let right = Kek::generate().unwrap();
    let wrong = Kek::generate().unwrap();
    assert_ne!(
        right.to_hex(),
        wrong.to_hex(),
        "the CSPRNG produced the same KEK twice; the rest of this test is vacuous"
    );

    SealedSecretStore::new(store_at(dir.path()).secrets(), right.clone())
        .put(SSH_HOST_KEY, PLAINTEXT)
        .expect("put");

    // Green: the right key opens it.
    assert_eq!(
        SealedSecretStore::new(store_at(dir.path()).secrets(), right)
            .get(SSH_HOST_KEY)
            .expect("get")
            .as_deref(),
        Some(PLAINTEXT)
    );

    // Red: the wrong key does not, and it does not return garbage either — it
    // errors, because GCM authenticates.
    let err = SealedSecretStore::new(store_at(dir.path()).secrets(), wrong)
        .get(SSH_HOST_KEY)
        .expect_err("a wrong KEK must not open the secret");
    let text = err.to_string();
    assert!(
        text.contains("did not authenticate"),
        "the wrong-KEK failure came out as something else, so this test is not \
         observing the tag check: {text}"
    );
}

/// The key name is the AEAD's associated data, so a sealed value cannot be
/// moved between slots.
///
/// Without this, anyone able to write the secret log (but not read the KEK)
/// could copy a low-value sealed secret over `ssh/host_ed25519`.
#[test]
fn a_sealed_secret_cannot_be_replayed_under_a_different_name() {
    let kek = Kek::generate().unwrap();
    let sealed = seal(&kek, "ssh/host_ed25519", PLAINTEXT).expect("seal");

    assert_eq!(
        unseal(&kek, "ssh/host_ed25519", &sealed).expect("same name opens"),
        PLAINTEXT
    );

    let err = unseal(&kek, "some/other_secret", &sealed)
        .expect_err("the same ciphertext must not open under another name");
    assert!(
        err.to_string().contains("did not authenticate"),
        "name binding failed for the wrong reason: {err}"
    );
}

/// A single flipped byte anywhere in the sealed value is refused.
///
/// Walked over the whole value rather than one hand-picked offset, because a
/// tamper check that only covers the payload and not the nonce is a real and
/// common defect.
#[test]
fn any_modified_byte_is_refused() {
    let kek = Kek::generate().unwrap();
    let sealed = seal(&kek, SSH_HOST_KEY, PLAINTEXT).expect("seal");
    assert!(is_sealed(&sealed));

    for offset in 0..sealed.len() {
        let mut tampered = sealed.clone();
        tampered[offset] ^= 0x01;
        assert!(
            unseal(&kek, SSH_HOST_KEY, &tampered).is_err(),
            "flipping a bit at offset {offset} of {} was accepted",
            sealed.len()
        );
    }
}

/// Two seals of the same plaintext must differ, or the nonce is dead.
///
/// A fixed nonce under a fixed key is catastrophic for GCM specifically, and it
/// is invisible to every round-trip test.
#[test]
fn sealing_the_same_plaintext_twice_gives_different_ciphertext() {
    let kek = Kek::generate().unwrap();
    let a = seal(&kek, SSH_HOST_KEY, PLAINTEXT).expect("seal");
    let b = seal(&kek, SSH_HOST_KEY, PLAINTEXT).expect("seal");
    assert_ne!(
        a, b,
        "two seals of identical plaintext under one KEK produced identical bytes: \
         the nonce is constant, which for AES-GCM is a key-recovery bug, not a \
         cosmetic one"
    );
    // Both still open, so `different` is not `broken`.
    assert_eq!(unseal(&kek, SSH_HOST_KEY, &a).unwrap(), PLAINTEXT);
    assert_eq!(unseal(&kek, SSH_HOST_KEY, &b).unwrap(), PLAINTEXT);
}

/// Sealing must not break the property the host key depends on most: racing
/// callers mint exactly one key.
///
/// The decorator delegates `get_or_create` to the backend and seals *inside*
/// the callback precisely so this stays the backend's guarantee. That is a
/// claim until eight threads have raced.
#[test]
fn sealing_does_not_break_get_or_create_atomicity() {
    let dir = tempfile::tempdir().expect("tempdir");
    let sealed = SealedSecretStore::new(store_at(dir.path()).secrets(), Kek::generate().unwrap());
    let calls = AtomicUsize::new(0);
    const THREADS: usize = 8;

    let values: Vec<Vec<u8>> = std::thread::scope(|scope| {
        let handles: Vec<_> = (0..THREADS)
            .map(|_| {
                let sealed = &sealed;
                let calls = &calls;
                scope.spawn(move || {
                    sealed.get_or_create_with(SSH_HOST_KEY, || {
                        let n = calls.fetch_add(1, Ordering::SeqCst);
                        std::thread::sleep(std::time::Duration::from_millis(5));
                        Ok(format!("host-key-{n}").into_bytes())
                    })
                })
            })
            .collect();
        handles
            .into_iter()
            .map(|h| h.join().expect("thread").expect("get_or_create"))
            .collect()
    });

    assert_eq!(
        calls.load(Ordering::SeqCst),
        1,
        "{THREADS} racing callers minted {} host keys through the sealed store",
        calls.load(Ordering::SeqCst)
    );
    assert!(
        values.windows(2).all(|w| w[0] == w[1]),
        "racing callers got different plaintext back: {values:?}"
    );
    assert_eq!(values[0], b"host-key-0");
}

/// Delete still deletes, and put still replaces — sealing changes no observable
/// `SecretStore` semantics.
#[test]
fn sealing_preserves_put_get_delete_semantics() {
    let dir = tempfile::tempdir().expect("tempdir");
    let sealed = SealedSecretStore::new(store_at(dir.path()).secrets(), Kek::generate().unwrap());

    assert_eq!(sealed.get("k").expect("get"), None);
    sealed.put("k", b"one").expect("put");
    assert_eq!(sealed.get("k").expect("get").as_deref(), Some(&b"one"[..]));
    sealed.put("k", b"two").expect("replace");
    assert_eq!(sealed.get("k").expect("get").as_deref(), Some(&b"two"[..]));
    sealed.delete("k").expect("delete");
    assert_eq!(sealed.get("k").expect("get"), None);
    sealed
        .delete("k")
        .expect("deleting an absent secret is not an error");
}

// ───────────────────────────── migration ─────────────────────────────────────

/// An appliance upgraded in place has a plaintext host key already stored.
/// It must keep booting, and it must end up sealed.
///
/// The store is built with [`SealedSecretStore::migrating`], which is the
/// server's answer on a machine where `legacy_secret_log` found a
/// `secrets.arrow` — the only machine where an unsealed value can be gunnar's
/// own. `SealedSecretStore::new` refuses one; that is
/// [`an_unsealed_value_is_refused_where_no_legacy_log_could_have_written_it`].
#[test]
fn a_legacy_plaintext_secret_is_readable_and_then_sealed_in_place() {
    let dir = tempfile::tempdir().expect("tempdir");

    // The world before sealing: written by a bare store, no sealing anywhere.
    store_at(dir.path())
        .secrets()
        .put(SSH_HOST_KEY, PLAINTEXT)
        .expect("legacy put");
    assert!(
        secrets_file_contains(dir.path(), PLAINTEXT),
        "the legacy fixture did not actually write plaintext, so this test proves \
         nothing about migration"
    );

    let kek = Kek::generate().unwrap();
    let sealed = SealedSecretStore::migrating(store_at(dir.path()).secrets(), kek.clone());

    // It reads, rather than bricking the appliance.
    assert_eq!(
        sealed.get(SSH_HOST_KEY).expect("get").as_deref(),
        Some(PLAINTEXT),
        "an in-place upgrade could not read its own existing host key"
    );

    // And the boot path — `ensure_host_key` calls `get_or_create` — seals it,
    // returning the SAME key rather than minting a new one.
    let same = sealed
        .get_or_create_with(SSH_HOST_KEY, || {
            panic!("mk must not run: the key already exists")
        })
        .expect("migrate");
    assert_eq!(same, PLAINTEXT, "migration changed the host key");
    drop(sealed);

    assert!(
        !secrets_file_contains(dir.path(), PLAINTEXT),
        "after migration the plaintext is STILL the current value in the log. The \
         re-seal did not happen, or the log's compaction kept the superseded \
         plaintext row alive."
    );
    assert!(
        secrets_file_contains(dir.path(), SEAL_MAGIC),
        "migration wrote no sealed value"
    );
    assert_eq!(
        SealedSecretStore::new(store_at(dir.path()).secrets(), kek)
            .get(SSH_HOST_KEY)
            .expect("get")
            .as_deref(),
        Some(PLAINTEXT),
        "the migrated secret does not open"
    );
}

/// **An unsealed value is a way past the seal that costs no KEK — unless the
/// store refuses it.**
///
/// The AAD binding stops a sealed value being moved from one slot to another.
/// It says nothing about a value that was never sealed, so a writer who can
/// reach the store and cannot read the KEK does not have to defeat the
/// cryptography: they write their own OpenSSH private key into
/// `ssh/host_ed25519` with no magic on the front, and a store that reads
/// plaintext through hands it to `ensure_host_key`. Every client then gets a
/// host key the attacker holds, with no `REMOTE HOST IDENTIFICATION HAS
/// CHANGED` to show for it, because as far as the clients are concerned the
/// server's identity is whatever the server presents.
///
/// Both entry points are checked, and that is the point rather than thoroughness
/// for its own sake: `get` is the read and `get_or_create` is the BOOT path. A
/// store that refused the read and adopted the same bytes at boot would refuse
/// nothing — worse, it would re-seal them, and the injected key would come back
/// authenticated forever after.
///
/// The green half is the test above: the same bytes, in the same slot, ARE
/// accepted by a store built with [`SealedSecretStore::migrating`]. Without that
/// pairing this test would also pass against a store that had simply stopped
/// working.
#[test]
fn an_unsealed_value_is_refused_where_no_legacy_log_could_have_written_it() {
    let dir = tempfile::tempdir().expect("tempdir");

    // The injection: a bare store, writing a private key straight into the slot
    // the SSH server reads its identity out of. No KEK was involved.
    store_at(dir.path())
        .secrets()
        .put(SSH_HOST_KEY, PLAINTEXT)
        .expect("inject");
    assert!(
        secrets_file_contains(dir.path(), PLAINTEXT),
        "the injection did not reach the file, so this test proves nothing"
    );

    let kek = Kek::generate().unwrap();
    let sealed = SealedSecretStore::new(store_at(dir.path()).secrets(), kek.clone());

    let err = sealed
        .get(SSH_HOST_KEY)
        .expect_err("a read must not hand out a value nothing authenticated");
    let text = format!("{err}");
    assert!(
        text.contains("carries no seal"),
        "the refusal must say what is wrong with the value: {text}"
    );

    sealed
        .get_or_create_with(SSH_HOST_KEY, || {
            panic!("mk must not run: a value is present, it is simply not gunnar's")
        })
        .expect_err("the boot path must refuse it too, and must not seal it in place");

    // …and the injected plaintext is still exactly that: refusing is not the
    // same as adopting it quietly, and nothing must have laundered it into a
    // sealed value that opens.
    assert!(
        secrets_file_contains(dir.path(), PLAINTEXT),
        "the refused value was rewritten; a refusal must not also be a migration"
    );
    assert!(
        !secrets_file_contains(dir.path(), SEAL_MAGIC),
        "the refused plaintext was sealed in place, which makes the attacker's key \
         permanent and authentic"
    );
}

// ───────────────────────────── KEK custody ───────────────────────────────────

/// The on-disk KEK is minted once, at `0600`, and is stable across reboots —
/// because a KEK that moves is every secret on the machine lost.
#[test]
fn the_on_disk_kek_is_created_once_owner_only_and_is_stable() {
    let dir = tempfile::tempdir().expect("tempdir");
    let path = kek_path(dir.path());
    assert!(
        !path.exists(),
        "the fixture started with a KEK already there"
    );

    let first = Kek::from_file(&path).expect("mint");
    assert!(path.exists(), "from_file minted a KEK but wrote no file");

    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        let mode = std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777;
        assert_eq!(
            mode, 0o600,
            "the KEK file is mode {mode:o}; anything group- or world-readable makes \
             sealing pointless on a shared box"
        );
    }

    let second = Kek::from_file(&path).expect("reload");
    assert_eq!(
        first.to_hex(),
        second.to_hex(),
        "the KEK changed across a reboot; every sealed secret on the machine is now \
         unreadable"
    );
    // The negative: a *different* file gets a different KEK, so the equality
    // above is not `to_hex` returning a constant.
    let other = Kek::from_file(&kek_path(&dir.path().join("elsewhere"))).expect("mint");
    assert_ne!(
        first.to_hex(),
        other.to_hex(),
        "two independently minted KEKs are identical; the CSPRNG is not running"
    );
    assert_eq!(first.to_hex().len(), 64, "a KEK is 32 bytes of hex");
}

/// A truncated or overlong KEK file is refused rather than silently padded.
///
/// Guessing here means every secret on the machine becomes unreadable, and a
/// helpful `resize` would do exactly that without saying so.
#[test]
fn a_wrong_length_kek_file_is_refused() {
    let dir = tempfile::tempdir().expect("tempdir");
    let path = kek_path(dir.path());
    std::fs::create_dir_all(dir.path()).unwrap();
    std::fs::write(&path, b"too short").unwrap();

    let err = Kek::from_file(&path).expect_err("a 9-byte KEK must be refused");
    assert!(
        err.to_string().contains("9 bytes"),
        "the refusal does not say what it found: {err}"
    );
}

/// `Debug` on a KEK must not print key material — the same rule
/// `HostKey`'s `Debug` already follows.
#[test]
fn a_kek_does_not_print_itself() {
    let kek = Kek::generate().unwrap();
    let printed = format!("{kek:?}");
    assert!(
        !printed.contains(&kek.to_hex()),
        "Debug for Kek printed the key: {printed}"
    );
    // And the probe is real: the hex genuinely is findable when you ask for it.
    assert!(kek.to_hex().contains(char::is_alphanumeric));
    assert_eq!(printed, "Kek(<redacted>)");
}

/// `is_sealed` must not mistake a plaintext OpenSSH key for ciphertext, or the
/// migration path would try to decrypt one and fail the boot.
#[test]
fn is_sealed_tells_ciphertext_from_a_legacy_plaintext() {
    assert!(!is_sealed(PLAINTEXT));
    assert!(!is_sealed(b""));
    assert!(
        !is_sealed(SEAL_MAGIC),
        "the magic alone is not a sealed value"
    );
    let kek = Kek::generate().unwrap();
    assert!(is_sealed(
        &seal(&kek, "k", b"").expect("seal an empty secret")
    ));
    assert!(is_sealed(&seal(&kek, "k", PLAINTEXT).expect("seal")));
}