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

//! **A vault written before key slots existed still opens.**
//!
//! The fixture is not a reconstruction. `tests/fixtures/v1.vault` was written by
//! the crate at commit `5acd844` — the last commit before the slot list — running
//! its own `Vault::create` and `set_with_meta`. That matters: a fixture this tree
//! generated from its own understanding of the v1 format would pass even if that
//! understanding were wrong, which is the one failure mode the test exists to
//! catch.
//!
//! Its PIN is `1111`; it holds a `SparkToken` under `plain` and a labelled
//! `Opaque` under `labelled` (label `git-ssh-key`), which between them exercise
//! both branches of the v1 entry AAD.

use nornir_vault::{PinProvider, SecretEntry, Vault};

const V1: &[u8] = include_bytes!("fixtures/v1.vault");

fn staged(name: &str) -> std::path::PathBuf {
    let dir = std::env::temp_dir().join(format!("nv-v1-{}-{}", std::process::id(), name));
    std::fs::create_dir_all(&dir).unwrap();
    let p = dir.join("v1.vault");
    std::fs::write(&p, V1).unwrap();
    p
}

#[test]
fn a_v1_file_opens_and_both_entry_shapes_decrypt() {
    let path = staged("open");
    let mut v = Vault::open(&path).unwrap();
    v.unlock(&PinProvider::new("1111")).unwrap();

    match v.get("plain").unwrap().unwrap() {
        SecretEntry::SparkToken(t) => assert_eq!(t.expose(), "v1-secret"),
        other => panic!("wrong kind: {other:?}"),
    }
    match v.get("labelled").unwrap().unwrap() {
        SecretEntry::Opaque(b) => assert_eq!(&**b, b"v1-opaque"),
        other => panic!("wrong kind: {other:?}"),
    }
    // The label survived, which means the labelled branch of the v1 AAD was
    // reproduced byte for byte — a single wrong byte there fails the tag.
    assert_eq!(v.entry_label("labelled"), Some("git-ssh-key"));
    assert_eq!(v.entry_added_at("labelled"), Some("2026-01-01T00:00:00Z"));

    // v1 recorded no author, and the honest answer is None rather than a guess.
    assert_eq!(v.entry_author("plain"), None);
}

#[test]
fn the_v1_key_becomes_one_slot_with_no_invented_holder() {
    let path = staged("fold");
    let v = Vault::open(&path).unwrap();
    let holders = v.holders();
    assert_eq!(holders.len(), 1, "one key in, one slot out");
    assert_eq!(
        holders[0].holder, "original-holder",
        "v1 stored no holder name, so the fold must use the placeholder rather \
         than invent an account that was never recorded"
    );
    assert_eq!(holders[0].method, nornir_vault::AuthMethod::PinCode);
}

#[test]
fn a_v1_vault_can_be_given_a_second_holder_without_re_encrypting() {
    // The migration path an operator actually walks: open the old file, add the
    // new person, and the old secrets are untouched ciphertext throughout.
    let path = staged("grant");
    let before: serde_json::Value =
        serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();

    let mut v = Vault::open(&path).unwrap();
    v.unlock(&PinProvider::new("1111")).unwrap();
    v.add_holder("bob", &PinProvider::new("2222"), None).unwrap();

    let after: serde_json::Value =
        serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
    assert_eq!(before["entries"], after["entries"], "the v1 entries were rewritten");
    assert_eq!(after["version"], 2, "saving rewrites the file in the new shape");
    assert!(after["method"].is_null(), "the v1 fields must not survive the save");
    assert!(after["wrapped_dek"].is_null());

    // Both keys now work, and the migrated slot still opens under the OLD AAD.
    v.lock();
    let mut a = Vault::open(&path).unwrap();
    a.unlock(&PinProvider::new("1111")).unwrap();
    let mut b = Vault::open(&path).unwrap();
    b.unlock(&PinProvider::new("2222")).unwrap();
    match b.get("plain").unwrap().unwrap() {
        SecretEntry::SparkToken(t) => assert_eq!(t.expose(), "v1-secret"),
        other => panic!("wrong kind: {other:?}"),
    }
}

#[test]
fn a_header_with_both_shapes_is_refused_rather_than_guessed() {
    // Two answers to "which wrap is the DEK" is the shape a bad merge or a
    // hand-edit produces. Picking one silently would mean a vault that opens
    // with a key the operator thinks they revoked.
    let path = staged("both");
    let mut doc: serde_json::Value =
        serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
    doc["slots"] = serde_json::json!([{
        "holder": "smuggled",
        "method": "PinCode",
        "provider": doc["provider"].clone(),
        "wrapped_dek": doc["wrapped_dek"].clone(),
    }]);
    std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap();

    // `Vault` deliberately has no `Debug` — it holds secrets — so the error is
    // taken off the `Err` arm rather than through `unwrap_err`.
    let err = match Vault::open(&path) {
        Ok(_) => panic!("a header with both shapes was accepted"),
        Err(e) => e.to_string(),
    };
    assert!(err.contains("both"), "must refuse by reason, got: {err}");
}