use super::*;
fn coord(entity: &str) -> Coordinate {
Coordinate::new(entity, "scope:test").expect("coordinate")
}
fn fixed_key(byte: u8) -> PayloadKey {
PayloadKey(Zeroizing::new([byte; KEY_LEN]))
}
#[test]
fn scope_for_is_deterministic_per_granularity() {
let coordinate = coord("entity:a");
let kind = EventKind::custom(0xF, 1);
let id = EventId::from(7u128);
for granularity in [
KeyScopeGranularity::PerEntity,
KeyScopeGranularity::PerCategory,
KeyScopeGranularity::PerTypeId,
KeyScopeGranularity::PerEvent,
] {
let first = scope_for(granularity, &coordinate, kind, id);
let second = scope_for(granularity, &coordinate, kind, id);
assert_eq!(first, second, "scope_for must be deterministic");
}
}
#[test]
fn scope_for_distinguishes_by_the_relevant_field_only() {
let kind = EventKind::custom(0xF, 1);
let id = EventId::from(7u128);
let entity_a = scope_for(KeyScopeGranularity::PerEntity, &coord("a"), kind, id);
let entity_b = scope_for(KeyScopeGranularity::PerEntity, &coord("b"), kind, id);
assert_ne!(
entity_a, entity_b,
"distinct entities must not share a scope"
);
let entity_a_other_kind = scope_for(
KeyScopeGranularity::PerEntity,
&coord("a"),
EventKind::custom(0xE, 2),
EventId::from(99u128),
);
assert_eq!(
entity_a, entity_a_other_kind,
"PerEntity ignores kind and id"
);
let cat_f1 = scope_for(KeyScopeGranularity::PerCategory, &coord("a"), kind, id);
let cat_f2 = scope_for(
KeyScopeGranularity::PerCategory,
&coord("a"),
EventKind::custom(0xF, 2),
id,
);
let cat_e1 = scope_for(
KeyScopeGranularity::PerCategory,
&coord("a"),
EventKind::custom(0xE, 1),
id,
);
assert_eq!(cat_f1, cat_f2, "same category shares a scope");
assert_ne!(cat_f1, cat_e1, "distinct categories split");
let type_f1 = scope_for(KeyScopeGranularity::PerTypeId, &coord("a"), kind, id);
let type_f2 = scope_for(
KeyScopeGranularity::PerTypeId,
&coord("a"),
EventKind::custom(0xF, 2),
id,
);
assert_ne!(type_f1, type_f2, "distinct kinds split under PerTypeId");
let evt_1 = scope_for(KeyScopeGranularity::PerEvent, &coord("a"), kind, id);
let evt_2 = scope_for(
KeyScopeGranularity::PerEvent,
&coord("a"),
kind,
EventId::from(8u128),
);
assert_ne!(evt_1, evt_2, "distinct ids split under PerEvent");
assert_ne!(entity_a, cat_f1);
assert_ne!(cat_f1, type_f1);
assert_ne!(type_f1, evt_1);
}
#[test]
fn default_granularity_is_per_entity() {
assert_eq!(
KeyScopeGranularity::default(),
KeyScopeGranularity::PerEntity
);
}
#[test]
fn seal_then_open_round_trips() {
let key = fixed_key(0x11);
let nonce = [0x22u8; NONCE_LEN];
let aad = b"associated";
let plaintext = b"top secret payload";
let ciphertext = key.seal(&nonce, aad, plaintext).expect("seal");
assert_ne!(
ciphertext.as_slice(),
plaintext,
"payload must be encrypted"
);
let recovered = key.open(&nonce, aad, &ciphertext).expect("open");
assert_eq!(
recovered.as_slice(),
plaintext,
"round-trip must recover plaintext"
);
}
#[test]
fn open_fails_on_wrong_key_nonce_or_aad() {
let key = fixed_key(0x11);
let nonce = [0x22u8; NONCE_LEN];
let aad = b"aad";
let ciphertext = key.seal(&nonce, aad, b"payload").expect("seal");
let wrong_key = fixed_key(0x33);
assert_eq!(
wrong_key.open(&nonce, aad, &ciphertext),
Err(KeyStoreError::Open),
"wrong key must fail"
);
let wrong_nonce = [0x44u8; NONCE_LEN];
assert_eq!(
key.open(&wrong_nonce, aad, &ciphertext),
Err(KeyStoreError::Open),
"wrong nonce must fail"
);
assert_eq!(
key.open(&nonce, b"other", &ciphertext),
Err(KeyStoreError::Open),
"wrong aad must fail"
);
let mut tampered = ciphertext.clone();
if let Some(first) = tampered.first_mut() {
*first ^= 0xFF;
}
assert_eq!(
key.open(&nonce, aad, &tampered),
Err(KeyStoreError::Open),
"tampered ciphertext must fail"
);
}
#[test]
fn get_or_create_mints_once_and_returns_the_same_key() {
let mut store = KeyStore::new(KeyScopeGranularity::PerEntity);
let scope = scope_for(
KeyScopeGranularity::PerEntity,
&coord("entity:mint"),
EventKind::custom(0xF, 1),
EventId::from(1u128),
);
let nonce = [0x01u8; NONCE_LEN];
let ciphertext = {
let key = store.get_or_create(&scope).expect("mint");
key.seal(&nonce, b"", b"same-key?").expect("seal")
};
let recovered = {
let key = store.get_or_create(&scope).expect("reuse");
key.open(&nonce, b"", &ciphertext)
.expect("open with reused key")
};
assert_eq!(recovered.as_slice(), b"same-key?");
}
#[test]
fn destroy_removes_key_and_shreds_prior_ciphertext() {
let mut store = KeyStore::new(KeyScopeGranularity::PerEvent);
let scope = scope_for(
KeyScopeGranularity::PerEvent,
&coord("entity:shred"),
EventKind::custom(0xF, 1),
EventId::from(5u128),
);
let nonce = [0x09u8; NONCE_LEN];
let ciphertext = {
let key = store.get_or_create(&scope).expect("mint");
key.seal(&nonce, b"", b"shred me").expect("seal")
};
assert!(store.get(&scope).is_some(), "key exists before destroy");
assert!(store.destroy(&scope), "destroy removes an existing key");
assert!(store.get(&scope).is_none(), "get after destroy is None");
assert!(
!store.destroy(&scope),
"destroying an absent scope is false"
);
let fresh = store.get_or_create(&scope).expect("re-mint");
assert_eq!(
fresh.open(&nonce, b"", &ciphertext),
Err(KeyStoreError::Open),
"post-shred key must not recover the old payload"
);
}
#[test]
fn generate_yields_distinct_keys() {
let a = PayloadKey::generate().expect("key a");
let b = PayloadKey::generate().expect("key b");
let nonce = [0u8; NONCE_LEN];
let ciphertext = a.seal(&nonce, b"", b"probe").expect("seal");
assert_eq!(
b.open(&nonce, b"", &ciphertext),
Err(KeyStoreError::Open),
"independent keys must differ"
);
}
#[test]
fn payload_key_debug_does_not_leak_bytes() {
let key = fixed_key(0xAB);
let rendered = format!("{key:?}");
assert!(
!rendered.contains("ab"),
"debug must not print hex key bytes: {rendered}"
);
assert!(
!rendered.contains("171"),
"debug must not print decimal key bytes: {rendered}"
);
assert!(
rendered.contains("PayloadKey"),
"debug still names the type: {rendered}"
);
}
#[test]
fn payload_aad_layout_is_versioned_length_prefixed_and_coordinate_bound() {
let coordinate = coord("entity:aad-layout");
let kind = EventKind::custom(0xF, 2);
let aad = payload_aad(&coordinate, kind, EventId::from(0x0102_0304_u128));
let entity = coordinate.entity().as_bytes();
let scope = coordinate.scope().as_bytes();
let mut expected = vec![0x01];
expected.extend_from_slice(
&u32::try_from(entity.len())
.expect("entity length fits u32")
.to_le_bytes(),
);
expected.extend_from_slice(entity);
expected.extend_from_slice(
&u32::try_from(scope.len())
.expect("scope length fits u32")
.to_le_bytes(),
);
expected.extend_from_slice(scope);
expected.extend_from_slice(&kind.as_raw_u16().to_le_bytes());
expected.extend_from_slice(&0x0102_0304_u128.to_be_bytes());
assert_eq!(
aad, expected,
"AAD must follow the documented layout exactly (version, len-prefixed \
entity/scope, kind le, id be)"
);
let other_coordinate = payload_aad(
&coord("entity:aad-layout-other"),
kind,
EventId::from(0x0102_0304_u128),
);
assert_ne!(
aad, other_coordinate,
"AAD must vary with the coordinate — a constant AAD makes ciphertext relocatable"
);
}
#[test]
fn payload_aad_binds_ciphertext_to_event_identity() {
let mut store = KeyStore::new(KeyScopeGranularity::PerEntity);
let coordinate = coord("entity:aad");
let kind = EventKind::custom(0xF, 1);
let scope = scope_for(
KeyScopeGranularity::PerEntity,
&coordinate,
kind,
EventId::from(1u128),
);
let key = store.get_or_create(&scope).expect("mint");
let nonce = [0x5u8; NONCE_LEN];
let aad_event_1 = payload_aad(&coordinate, kind, EventId::from(1u128));
let ciphertext = key
.seal(&nonce, &aad_event_1, b"bound secret")
.expect("seal");
let aad_event_2 = payload_aad(&coordinate, kind, EventId::from(2u128));
assert_eq!(
key.open(&nonce, &aad_event_2, &ciphertext),
Err(KeyStoreError::Open),
"relocating the ciphertext onto a different event id must fail to open"
);
let other_coord = coord("entity:other");
let aad_other = payload_aad(&other_coord, kind, EventId::from(1u128));
assert_eq!(
key.open(&nonce, &aad_other, &ciphertext),
Err(KeyStoreError::Open),
"relocating the ciphertext onto a different coordinate must fail to open"
);
assert_eq!(
key.open(&nonce, &aad_event_1, &ciphertext).expect("open"),
b"bound secret",
);
}
#[test]
fn resolve_shred_scope_matches_the_configured_granularity_and_is_byte_identical_to_scope_for() {
let coordinate = coord("entity:resolve");
let kind = EventKind::custom(0xF, 0x2A);
let id = EventId::from(0x99u128);
let entity_sel = ShredScope::Entity(&coordinate);
assert_eq!(
KeyScopeGranularity::PerEntity.resolve_shred_scope(&entity_sel),
Some(scope_for(
KeyScopeGranularity::PerEntity,
&coordinate,
kind,
id
)),
"PerEntity+Entity must resolve to the byte-identical per-entity scope"
);
let kind_sel = ShredScope::Kind(kind);
assert_eq!(
KeyScopeGranularity::PerCategory.resolve_shred_scope(&kind_sel),
Some(scope_for(
KeyScopeGranularity::PerCategory,
&coordinate,
kind,
id
)),
"PerCategory+Kind must resolve to the per-category scope"
);
assert_eq!(
KeyScopeGranularity::PerTypeId.resolve_shred_scope(&kind_sel),
Some(scope_for(
KeyScopeGranularity::PerTypeId,
&coordinate,
kind,
id
)),
"PerTypeId+Kind must resolve to the per-type-id scope"
);
let event_sel = ShredScope::Event(id);
assert_eq!(
KeyScopeGranularity::PerEvent.resolve_shred_scope(&event_sel),
Some(scope_for(
KeyScopeGranularity::PerEvent,
&coordinate,
kind,
id
)),
"PerEvent+Event must resolve to the per-event scope"
);
}
#[test]
fn resolve_shred_scope_returns_none_on_a_selector_that_cannot_address_the_granularity() {
let coordinate = coord("entity:mismatch");
let kind = EventKind::custom(0xE, 3);
let id = EventId::from(4u128);
assert_eq!(
KeyScopeGranularity::PerEntity.resolve_shred_scope(&ShredScope::Kind(kind)),
None,
"a Kind selector cannot address a PerEntity store"
);
assert_eq!(
KeyScopeGranularity::PerEntity.resolve_shred_scope(&ShredScope::Event(id)),
None,
"an Event selector cannot address a PerEntity store"
);
assert_eq!(
KeyScopeGranularity::PerCategory.resolve_shred_scope(&ShredScope::Entity(&coordinate)),
None,
"an Entity selector cannot address a PerCategory store"
);
assert_eq!(
KeyScopeGranularity::PerEvent.resolve_shred_scope(&ShredScope::Kind(kind)),
None,
"a Kind selector cannot address a PerEvent store"
);
}
#[test]
fn shred_scope_label_names_each_selector_variant() {
let coordinate = coord("entity:label");
assert_eq!(ShredScope::Entity(&coordinate).label(), "Entity");
assert_eq!(ShredScope::Kind(EventKind::custom(0xF, 1)).label(), "Kind");
assert_eq!(ShredScope::Event(EventId::from(1u128)).label(), "Event");
}
#[test]
fn scope_discriminant_bytes_are_stable_and_distinct_per_granularity() {
let coordinate = coord("entity:disc");
let kind = EventKind::custom(0xF, 1);
let id = EventId::from(1u128);
assert_eq!(
scope_for(KeyScopeGranularity::PerEntity, &coordinate, kind, id).as_bytes()[0],
0x01,
"PerEntity discriminant"
);
assert_eq!(
scope_for(KeyScopeGranularity::PerCategory, &coordinate, kind, id).as_bytes()[0],
0x02,
"PerCategory discriminant"
);
assert_eq!(
scope_for(KeyScopeGranularity::PerTypeId, &coordinate, kind, id).as_bytes()[0],
0x03,
"PerTypeId discriminant"
);
assert_eq!(
scope_for(KeyScopeGranularity::PerEvent, &coordinate, kind, id).as_bytes()[0],
0x04,
"PerEvent discriminant"
);
}
#[test]
fn granularity_accessor_echoes_the_configured_value_not_the_default() {
let store = KeyStore::new(KeyScopeGranularity::PerEvent);
assert_eq!(
store.granularity(),
KeyScopeGranularity::PerEvent,
"granularity() must echo the configured granularity, not the PerEntity default"
);
assert_ne!(store.granularity(), KeyScopeGranularity::default());
}
#[test]
fn key_count_tracks_live_keys_and_is_not_a_constant_zero() {
let mut store = KeyStore::new(KeyScopeGranularity::PerEvent);
assert_eq!(store.key_count(), 0, "a fresh key store holds no keys");
let scope_a = scope_for(
KeyScopeGranularity::PerEvent,
&coord("entity:count"),
EventKind::custom(0xF, 1),
EventId::from(1u128),
);
let scope_b = scope_for(
KeyScopeGranularity::PerEvent,
&coord("entity:count"),
EventKind::custom(0xF, 1),
EventId::from(2u128),
);
store.get_or_create(&scope_a).expect("mint a");
store.get_or_create(&scope_b).expect("mint b");
assert_eq!(
store.key_count(),
2,
"two distinct scopes mint two live keys"
);
assert!(store.destroy(&scope_a), "destroy an existing key");
assert_eq!(
store.key_count(),
1,
"destroy drops the live-key count by one"
);
}
#[test]
fn dirty_flag_starts_clean_and_only_mark_dirty_or_destroy_sets_it() {
let mut store = KeyStore::new(KeyScopeGranularity::PerEntity);
assert!(!store.is_dirty(), "a fresh key store is not dirty");
let scope = scope_for(
KeyScopeGranularity::PerEntity,
&coord("entity:dirty"),
EventKind::custom(0xF, 1),
EventId::from(1u128),
);
store.get_or_create(&scope).expect("mint");
assert!(
!store.is_dirty(),
"get_or_create must not by itself mark the keyset dirty"
);
assert!(
store.destroy(&scope),
"destroying a present key returns true"
);
assert!(
store.is_dirty(),
"destroy of a present key must flag the keyset dirty (erasure pending flush)"
);
let mut clean = KeyStore::new(KeyScopeGranularity::PerEntity);
let absent = scope_for(
KeyScopeGranularity::PerEntity,
&coord("entity:absent"),
EventKind::custom(0xF, 1),
EventId::from(9u128),
);
assert!(
!clean.destroy(&absent),
"destroying an absent scope returns false"
);
assert!(
!clean.is_dirty(),
"a no-op destroy must not flag the keyset dirty"
);
clean.mark_dirty();
assert!(store.is_dirty(), "the destroyed store is still dirty");
assert!(clean.is_dirty(), "mark_dirty must set the dirty signal");
}
#[test]
fn key_store_error_display_is_the_exact_opaque_message_per_variant() {
assert_eq!(
KeyStoreError::Rng.to_string(),
"CSPRNG failed to produce key material"
);
assert_eq!(
KeyStoreError::KeyInit.to_string(),
"AEAD key initialization rejected the key length"
);
assert_eq!(
KeyStoreError::Seal.to_string(),
"authenticated encryption failed"
);
assert_eq!(
KeyStoreError::Open.to_string(),
"authenticated decryption failed"
);
}