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

nordisk/edda

crates/nornir-vault/tests/roundtrip.rs

//! End-to-end vault behaviour: PIN round-trip, wrong-PIN lockout, the
//! no-plaintext-on-disk invariant, zeroization, and the secret-free state view.

use nornir_vault::{
    AuthMethod, EntryKind, OidcOpenBaoProvider, PinProvider, SecretEntry, SecretString, Vault,
    VaultError,
};
use zeroize::Zeroize;

const TOKEN_NEEDLE: &str = "SPARK-TOKEN-NEEDLE-8f3a1c2b-do-not-persist-plaintext";
const CERT_NEEDLE: &str = "CERT-PEM-NEEDLE-BEGIN-CERTIFICATE-zzz";
const KEY_NEEDLE: &str = "KEY-PEM-NEEDLE-BEGIN-PRIVATE-KEY-yyy";

fn seed(v: &mut Vault) {
    v.set("spark", SecretEntry::SparkToken(SecretString::new(TOKEN_NEEDLE)))
        .unwrap();
    v.set(
        "mtls",
        SecretEntry::MtlsClientIdentity {
            cert_pem: SecretString::new(CERT_NEEDLE),
            key_pem: SecretString::new(KEY_NEEDLE),
        },
    )
    .unwrap();
}

#[test]
fn pin_roundtrip_encrypt_lock_unlock_read() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("korp.vault");

    let mut v = Vault::create(&path, &PinProvider::new("4913")).unwrap();
    seed(&mut v);
    assert!(!v.is_locked());
    v.lock();
    assert!(v.is_locked());
    // Reading while locked is an error, not a silent empty.
    assert!(matches!(v.get("spark"), Err(VaultError::Locked)));

    // Re-open cold from disk and unlock with the correct PIN.
    let mut v = Vault::open(&path).unwrap();
    assert!(v.is_locked());
    v.unlock(&PinProvider::new("4913")).unwrap();

    match v.get("spark").unwrap() {
        Some(SecretEntry::SparkToken(t)) => assert_eq!(t.expose(), TOKEN_NEEDLE),
        other => panic!("expected spark token, got {other:?}"),
    }
    match v.get("mtls").unwrap() {
        Some(SecretEntry::MtlsClientIdentity { cert_pem, key_pem }) => {
            assert_eq!(cert_pem.expose(), CERT_NEEDLE);
            assert_eq!(key_pem.expose(), KEY_NEEDLE);
        }
        other => panic!("expected mtls identity, got {other:?}"),
    }
    assert!(v.get("absent").unwrap().is_none());
}

#[test]
fn wrong_pin_fails_and_increments_lockout() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("korp.vault");

    let mut v = Vault::create(&path, &PinProvider::new("1234")).unwrap();
    seed(&mut v);
    v.lock();

    // First wrong attempt: fails, and the persisted counter drops by one.
    let mut v = Vault::open(&path).unwrap();
    let before = v.attempts_remaining();
    let err = v.unlock(&PinProvider::new("0000")).unwrap_err();
    match err {
        VaultError::Unlock { remaining } => assert_eq!(remaining, before - 1),
        other => panic!("expected Unlock, got {other:?}"),
    }
    assert!(v.is_locked(), "a failed unlock must leave the vault locked");

    // The decrement is persisted: a *fresh* open sees the reduced budget.
    let v2 = Vault::open(&path).unwrap();
    assert_eq!(v2.attempts_remaining(), before - 1, "lockout must survive reopen");

    // The correct PIN still works and resets the counter to full.
    let mut v3 = Vault::open(&path).unwrap();
    v3.unlock(&PinProvider::new("1234")).unwrap();
    assert_eq!(v3.attempts_remaining(), before, "success resets the counter");
    // Reset is persisted.
    let v4 = Vault::open(&path).unwrap();
    assert_eq!(v4.attempts_remaining(), before);
}

#[test]
fn lockout_trips_after_budget_exhausted() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("korp.vault");
    let mut v = Vault::create(&path, &PinProvider::new("1234")).unwrap();
    seed(&mut v);
    let budget = v.attempts_remaining();
    v.lock();

    let mut v = Vault::open(&path).unwrap();
    for _ in 0..budget {
        assert!(matches!(
            v.unlock(&PinProvider::new("9999")),
            Err(VaultError::Unlock { .. })
        ));
    }
    // Budget spent → the correct PIN is now refused too (hard backstop).
    assert!(matches!(v.unlock(&PinProvider::new("1234")), Err(VaultError::LockedOut)));
}

#[test]
fn no_plaintext_secret_is_ever_written_to_disk() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("korp.vault");
    let mut v = Vault::create(&path, &PinProvider::new("4913")).unwrap();
    seed(&mut v);
    v.lock();

    let raw = std::fs::read(&path).unwrap();
    // The file must be a valid vault header (JSON) …
    let _hdr: serde_json::Value = serde_json::from_slice(&raw).unwrap();
    // … and must NOT contain any secret's plaintext bytes.
    let hay = raw.as_slice();
    for needle in [TOKEN_NEEDLE, CERT_NEEDLE, KEY_NEEDLE] {
        assert!(
            !contains(hay, needle.as_bytes()),
            "on-disk vault leaked plaintext for needle: {needle}"
        );
    }
}

#[test]
fn state_view_carries_no_secret_bytes() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("korp.vault");
    let mut v = Vault::create(&path, &PinProvider::new("4913")).unwrap();
    seed(&mut v);

    let state = v.state();
    assert_eq!(state.method, AuthMethod::PinCode);
    assert!(!state.locked);
    let kinds: Vec<_> = state.entries.iter().map(|e| (e.name.as_str(), e.kind)).collect();
    assert!(kinds.contains(&("spark", EntryKind::SparkToken)));
    assert!(kinds.contains(&("mtls", EntryKind::MtlsClientIdentity)));

    // Serialising the state (what a UI would put in state_json) leaks nothing.
    let json = serde_json::to_string(&state).unwrap();
    for needle in [TOKEN_NEEDLE, CERT_NEEDLE, KEY_NEEDLE] {
        assert!(!json.contains(needle), "state_json leaked {needle}");
    }
}

#[test]
fn secretstring_zeroizes_and_redacts() {
    // Zeroize wipes the bytes.
    let mut s = SecretString::new("super-secret-value");
    assert_eq!(s.expose(), "super-secret-value");
    s.zeroize();
    assert!(s.expose().is_empty(), "zeroize must clear the secret");

    // Debug is redacted, never revealing.
    let s = SecretString::new("hunter2");
    assert_eq!(format!("{s:?}"), "SecretString(***)");
    let e = SecretEntry::SparkToken(SecretString::new("hunter2"));
    let dbg = format!("{e:?}");
    assert!(!dbg.contains("hunter2"), "SecretEntry Debug must not reveal the secret");
    assert!(dbg.contains("***"));
}

#[test]
fn provider_method_mismatch_is_rejected() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("korp.vault");
    let mut v = Vault::create(&path, &PinProvider::new("4913")).unwrap();
    v.lock();
    // A PIN vault refuses a non-PIN provider before touching crypto.
    let err = v.unlock(&OidcOpenBaoProvider).unwrap_err();
    assert!(matches!(err, VaultError::MethodMismatch { .. }));
}

#[test]
fn design_only_providers_do_not_pretend() {
    use nornir_vault::VaultKeyProvider;
    assert!(matches!(
        OidcOpenBaoProvider.provision(),
        Err(VaultError::ProviderUnimplemented(_))
    ));
}

/// Naive substring search over bytes.
fn contains(hay: &[u8], needle: &[u8]) -> bool {
    hay.windows(needle.len()).any(|w| w == needle)
}

// ── The forge-vault metadata: freeform label + added_at (dwarves absorption) ──

/// **RED-when-broken: the freeform label round-trips and is AUTHENTICATED.**
///
/// A forge vault stores opaque bytes under a sub-kind (`git-ssh-key`,
/// `crates-token`). The label is non-secret and printable, but it must not be
/// editable behind the AEAD's back — so it is bound into the entry AAD. Editing
/// it in the on-disk JSON must make the entry fail to open, not silently relabel
/// a private key as something else.
#[test]
fn a_labelled_entry_round_trips_and_the_label_cannot_be_edited_on_disk() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("forge.vault");

    let mut v = Vault::create(&path, &PinProvider::new("4913")).unwrap();
    v.set_with_meta(
        "deploy",
        SecretEntry::Opaque(zeroize::Zeroizing::new(b"PRIVATE-KEY-BYTES".to_vec())),
        Some("git-ssh-key"),
        Some("2026-08-21T20:00:00Z"),
    )
    .unwrap();
    v.lock();

    // Round-trip: label + stamp survive, bytes survive.
    let mut v = Vault::open(&path).unwrap();
    v.unlock(&PinProvider::new("4913")).unwrap();
    assert_eq!(v.entry_label("deploy"), Some("git-ssh-key"));
    assert_eq!(v.entry_added_at("deploy"), Some("2026-08-21T20:00:00Z"));
    match v.get("deploy").unwrap() {
        Some(SecretEntry::Opaque(b)) => assert_eq!(&b[..], b"PRIVATE-KEY-BYTES"),
        other => panic!("expected the opaque bytes, got {other:?}"),
    }
    // The label is visible in the secret-free state view — it is metadata, not a secret.
    let st = v.state();
    let e = st.entries.iter().find(|e| e.name == "deploy").expect("entry present");
    assert_eq!(e.label.as_deref(), Some("git-ssh-key"));
    assert_eq!(e.kind, EntryKind::Opaque);

    // TAMPER: relabel the entry in the file. The AAD no longer matches → unlock fails.
    let raw = std::fs::read_to_string(&path).unwrap();
    assert!(raw.contains("git-ssh-key"), "the label is stored in the clear (it is not a secret)");
    std::fs::write(&path, raw.replace("git-ssh-key", "crates-token")).unwrap();
    let mut v = Vault::open(&path).unwrap();
    assert!(
        v.unlock(&PinProvider::new("4913")).is_err(),
        "relabelling a sealed entry on disk MUST fail the tag, never silently succeed"
    );
}

/// **RED-when-broken: a vault written before the label field existed still opens.**
///
/// The absorption added two `Option` fields to every entry record. Their absence
/// must produce the byte-exact pre-label AAD, or every vault on every box stops
/// opening on upgrade. Proven by writing a labelless entry, then stripping the
/// (already absent) keys and re-reading — plus an explicit assert that a `None`
/// label leaves no key in the JSON at all.
#[test]
fn an_unlabelled_entry_keeps_the_historical_on_disk_shape_and_opens() {
    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("legacy.vault");

    let mut v = Vault::create(&path, &PinProvider::new("4913")).unwrap();
    v.set("spark", SecretEntry::SparkToken(SecretString::new(TOKEN_NEEDLE))).unwrap();
    v.lock();

    // No label ⇒ the keys are absent from the file entirely (skip_serializing_if),
    // so the document is byte-shaped exactly as it was before the fields existed.
    let raw = std::fs::read_to_string(&path).unwrap();
    assert!(!raw.contains("\"label\""), "an unlabelled entry must not write a label key");
    assert!(!raw.contains("\"added_at\""), "an unlabelled entry must not write an added_at key");

    let mut v = Vault::open(&path).unwrap();
    v.unlock(&PinProvider::new("4913")).unwrap();
    assert_eq!(v.entry_label("spark"), None);
    match v.get("spark").unwrap() {
        Some(SecretEntry::SparkToken(t)) => assert_eq!(t.expose(), TOKEN_NEEDLE),
        other => panic!("expected the token, got {other:?}"),
    }
}

/// **RED-when-broken: a cached KEK unlocks the same vault without re-running the
/// KDF, is bound to that vault, and cannot create a new one.**
///
/// This is the seam a CLI's `unlock`/`lock` session cache stands on: derive once,
/// reuse across a burst of short-lived processes. Three arms, because two of them
/// are what keep it from being a back door.
#[test]
fn a_cached_kek_unlocks_the_same_vault_but_cannot_provision_one() {
    use nornir_vault::{CachedKekProvider, VaultKeyProvider};

    let dir = tempfile::tempdir().unwrap();
    let path = dir.path().join("session.vault");

    let mut v = Vault::create(&path, &PinProvider::new("4913")).unwrap();
    v.set("spark", SecretEntry::SparkToken(SecretString::new(TOKEN_NEEDLE))).unwrap();
    // Derive the KEK the way a session cache would: once, from the vault's own params.
    let kek = PinProvider::new("4913").kek(v.provider_params()).unwrap();
    v.lock();

    // ARM 1 — the cached KEK opens it, no PIN involved.
    let mut v = Vault::open(&path).unwrap();
    v.unlock(&CachedKekProvider::new(kek.clone(), v.method())).unwrap();
    match v.get("spark").unwrap() {
        Some(SecretEntry::SparkToken(t)) => assert_eq!(t.expose(), TOKEN_NEEDLE),
        other => panic!("expected the token, got {other:?}"),
    }

    // ARM 2 — it is bound to THIS vault: another vault's KEK does not open it,
    // and the failure is a normal wrong-credential unlock (the counter moves).
    let other = dir.path().join("other.vault");
    let ov = Vault::create(&other, &PinProvider::new("4913")).unwrap();
    let other_kek = PinProvider::new("4913").kek(ov.provider_params()).unwrap();
    let mut v = Vault::open(&path).unwrap();
    assert!(
        v.unlock(&CachedKekProvider::new(other_kek, v.method())).is_err(),
        "a KEK derived against another vault's salt must NOT open this one"
    );
    assert!(v.attempts_remaining() < 5, "a wrong cached KEK must burn an attempt like any other");

    // ARM 3 — it cannot stand in for a real KDF run when CREATING a vault.
    let fresh = dir.path().join("fresh.vault");
    let err = match Vault::create(&fresh, &CachedKekProvider::new(kek, AuthMethod::PinCode)) {
        Err(e) => e,
        Ok(_) => panic!("a recycled KEK must not be able to create a vault"),
    };
    assert!(
        format!("{err}").contains("cannot provision"),
        "creating a vault from a recycled KEK must be refused BY NAME, got: {err}"
    );
    assert!(!fresh.exists(), "the refused create must not leave a file behind");
}