use std::collections::HashMap;
use aes_gcm::{
Aes256Gcm, Nonce,
aead::{Aead, KeyInit, OsRng},
};
use ed25519_dalek::SigningKey;
use rand::RngCore;
use sha2::{Digest, Sha256};
use zeroize::Zeroize;
use super::{
KeyRecord, KeyRecordMap, KeyStore, compute_envelope_hmac, default_algorithm,
derive_aes_key_sha256,
};
fn temp_store() -> KeyStore {
let dir = std::env::temp_dir();
let path = dir.join(format!("test-keystore-{}.json", uuid::Uuid::now_v7()));
KeyStore::open(path, "test-passphrase").expect("open key store")
}
#[test]
fn generate_then_load_roundtrip() {
let store = temp_store();
let generated = store.generate_key("issuer-1").expect("generate key");
let loaded = store.load_key("issuer-1").expect("load key");
assert_eq!(generated.fingerprint, loaded.fingerprint);
assert_eq!(
generated.verifying_key.as_bytes(),
loaded.verifying_key.as_bytes()
);
}
#[test]
fn stored_bytes_differ_from_plaintext() {
let dir = std::env::temp_dir();
let path = dir.join(format!("test-keystore-enc-{}.json", uuid::Uuid::now_v7()));
let store = KeyStore::open(&path, "test-passphrase").expect("open");
let key = store.generate_key("issuer-enc").expect("generate");
let raw_file = std::fs::read_to_string(&path).expect("read file");
let plaintext_hex = hex::encode(key.signing_key.as_bytes());
assert!(
!raw_file.contains(&plaintext_hex),
"plaintext key bytes must not appear in the store file"
);
}
#[test]
fn store_file_contains_argon2id_kdf_marker() {
let dir = std::env::temp_dir();
let path = dir.join(format!("test-keystore-kdf-{}.json", uuid::Uuid::now_v7()));
let store = KeyStore::open(&path, "test-passphrase").expect("open");
store.generate_key("issuer-kdf").expect("generate");
let raw_file = std::fs::read_to_string(&path).expect("read file");
assert!(
raw_file.contains("argon2id"),
"store file must contain argon2id KDF marker"
);
assert!(
raw_file.contains("salt"),
"store file must contain salt field"
);
}
#[test]
fn reopen_store_from_disk_with_argon2id() {
let dir = std::env::temp_dir();
let path = dir.join(format!(
"test-keystore-reopen-{}.json",
uuid::Uuid::now_v7()
));
let store = KeyStore::open(&path, "my-passphrase").expect("open");
let generated = store.generate_key("issuer-reopen").expect("generate");
drop(store);
let store2 = KeyStore::open(&path, "my-passphrase").expect("reopen");
let loaded = store2.load_key("issuer-reopen").expect("load");
assert_eq!(generated.fingerprint, loaded.fingerprint);
}
#[test]
fn legacy_sha256_store_can_be_opened_and_migrated() {
let dir = std::env::temp_dir();
let path = dir.join(format!(
"test-keystore-legacy-{}.json",
uuid::Uuid::now_v7()
));
let passphrase = "legacy-pass";
let legacy_key = derive_aes_key_sha256(passphrase);
let cipher = Aes256Gcm::new(&legacy_key);
let signing_key = SigningKey::generate(&mut OsRng);
let verifying_key = signing_key.verifying_key();
let fingerprint = hex::encode(Sha256::digest(verifying_key.as_bytes()));
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let mut raw = signing_key.to_bytes();
let encrypted = cipher.encrypt(nonce, raw.as_ref()).expect("encrypt");
raw.zeroize();
let record = KeyRecord {
encrypted_signing_key: encrypted,
nonce: nonce_bytes.to_vec(),
fingerprint: fingerprint.clone(),
verifying_key_hex: hex::encode(verifying_key.as_bytes()),
revoked: false,
algorithm: default_algorithm(),
};
let mut map = HashMap::new();
map.insert("legacy-key".to_string(), record);
let bytes = serde_json::to_vec(&map).expect("serialize");
std::fs::write(&path, bytes).expect("write");
let store = KeyStore::open(&path, passphrase).expect("open legacy store");
let loaded = store.load_key("legacy-key").expect("load legacy key");
assert_eq!(loaded.fingerprint, fingerprint);
store
.migrate_if_needed(passphrase)
.expect("migration failed");
let raw_file = std::fs::read_to_string(&path).expect("read");
assert!(raw_file.contains("argon2id"), "file must be migrated");
}
#[test]
fn archive_key_creates_archived_entry() {
let store = temp_store();
store.generate_key("issuer-arc").expect("generate");
store.archive_key("issuer-arc").expect("archive");
let archived = store.load_archived_keys("issuer-arc");
assert_eq!(archived.len(), 1, "expected one archived key");
}
#[test]
fn load_archived_keys_empty_before_rotation() {
let store = temp_store();
store.generate_key("issuer-noarc").expect("generate");
let archived = store.load_archived_keys("issuer-noarc");
assert!(
archived.is_empty(),
"no archived keys before first rotation"
);
}
#[test]
fn rotate_key_archives_old_and_installs_new() {
let store = temp_store();
let k1 = store.generate_key("iss").expect("gen");
let k2 = store.rotate_key("iss").expect("rotate");
assert_ne!(
k1.fingerprint, k2.fingerprint,
"rotation installs a new key"
);
assert_eq!(
store.load_key("iss").unwrap().fingerprint,
k2.fingerprint,
"current key is the new one"
);
let archived = store.load_archived_keys("iss");
assert_eq!(archived.len(), 1);
assert_eq!(archived[0].fingerprint, k1.fingerprint);
assert!(
!archived[0].revoked,
"hygiene rotation keeps the old key valid"
);
}
#[test]
fn revoke_and_rotate_marks_old_key_revoked() {
let store = temp_store();
let k1 = store.generate_key("iss").expect("gen");
let k2 = store.revoke_and_rotate("iss").expect("revoke+rotate");
assert_ne!(k1.fingerprint, k2.fingerprint);
assert!(!k2.revoked, "the new current key is not revoked");
let archived = store.load_archived_keys("iss");
assert_eq!(archived.len(), 1);
assert_eq!(archived[0].fingerprint, k1.fingerprint);
assert!(
archived[0].revoked,
"compromise rotation marks the old key revoked"
);
}
#[test]
fn revoked_state_persists_across_reopen() {
let dir = std::env::temp_dir();
let path = dir.join(format!(
"test-keystore-revoke-{}.json",
uuid::Uuid::now_v7()
));
let store = KeyStore::open(&path, "rev").expect("open");
store.generate_key("iss").expect("gen");
let revoked_fp = store.load_key("iss").unwrap().fingerprint.clone();
store.revoke_and_rotate("iss").expect("revoke+rotate");
drop(store);
let store2 = KeyStore::open(&path, "rev").expect("reopen");
let archived = store2.load_archived_keys("iss");
assert!(
archived
.iter()
.any(|k| k.fingerprint == revoked_fp && k.revoked),
"revoked flag must survive a reopen (and the HMAC must still verify)"
);
}
#[test]
fn store_file_contains_hmac() {
let dir = std::env::temp_dir();
let path = dir.join(format!("test-keystore-hmac-{}.json", uuid::Uuid::now_v7()));
let store = KeyStore::open(&path, "hmac-test").expect("open");
store.generate_key("issuer-hmac").expect("generate");
let raw = std::fs::read_to_string(&path).expect("read file");
assert!(
raw.contains("\"hmac\""),
"store file must contain HMAC field"
);
let envelope: serde_json::Value = serde_json::from_str(&raw).unwrap();
let hmac_val = envelope["hmac"].as_str().unwrap();
assert_eq!(hmac_val.len(), 64, "HMAC must be 64 hex chars");
}
#[test]
fn tampered_store_file_rejected_on_open() {
let dir = std::env::temp_dir();
let path = dir.join(format!(
"test-keystore-tamper-{}.json",
uuid::Uuid::now_v7()
));
let store = KeyStore::open(&path, "tamper-test").expect("open");
store.generate_key("issuer-tamper").expect("generate");
drop(store);
let mut raw = std::fs::read_to_string(&path).expect("read");
if let Some(pos) = raw.find("\"fingerprint\":\"") {
let fp_start = pos + "\"fingerprint\":\"".len();
let old_char = raw.as_bytes()[fp_start];
let new_char = if old_char == b'a' { b'b' } else { b'a' };
unsafe {
raw.as_bytes_mut()[fp_start] = new_char;
}
}
std::fs::write(&path, &raw).expect("write tampered file");
let result = KeyStore::open(&path, "tamper-test");
assert!(
result.is_err(),
"tampered store should fail integrity check"
);
let Err(e) = result else {
panic!("tampered store should fail integrity check");
};
let err = e.to_string();
assert!(
err.contains("integrity") || err.contains("tamper"),
"error should mention integrity, got: {err}"
);
}
#[test]
fn reopen_with_hmac_succeeds() {
let dir = std::env::temp_dir();
let path = dir.join(format!(
"test-keystore-hmac-ok-{}.json",
uuid::Uuid::now_v7()
));
let store = KeyStore::open(&path, "hmac-ok").expect("open");
store.generate_key("issuer-ok").expect("generate");
drop(store);
let store2 = KeyStore::open(&path, "hmac-ok").expect("reopen");
let loaded = store2.load_key("issuer-ok").expect("load");
assert!(!loaded.fingerprint.is_empty());
}
#[test]
fn hmac_is_stable_across_map_insertion_order() {
let integrity_key = [42u8; 32];
let make_record = |n: u8| KeyRecord {
encrypted_signing_key: vec![n; 48],
nonce: vec![n; 12],
fingerprint: format!("fp{n:02x}"),
verifying_key_hex: format!("{:064x}", n),
revoked: false,
algorithm: default_algorithm(),
};
let keys_fwd = ["zebra", "alpha", "mango", "delta", "beta"];
let mut map_a: KeyRecordMap = HashMap::new();
for (i, k) in keys_fwd.iter().enumerate() {
map_a.insert(k.to_string(), make_record(i as u8));
}
let mut map_b: KeyRecordMap = HashMap::new();
for (i, k) in keys_fwd.iter().enumerate() {
map_b.insert(k.to_string(), make_record(i as u8));
}
let entries: Vec<_> = map_b.drain().collect();
let mut map_b: KeyRecordMap = HashMap::new();
for (k, v) in entries.into_iter().rev() {
map_b.insert(k, v);
}
let test_salt = "dGVzdHNhbHQ="; let hmac_a = compute_envelope_hmac(&integrity_key, "argon2id", test_salt, &map_a).unwrap();
let hmac_b = compute_envelope_hmac(&integrity_key, "argon2id", test_salt, &map_b).unwrap();
assert_eq!(
hmac_a, hmac_b,
"HMAC must be identical regardless of HashMap insertion/iteration order"
);
}