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