use std::collections::BTreeMap;
use proptest::prelude::*;
use crate::crypto::{MurkIdentity, MurkRecipient};
use crate::testutil::{empty_vault, generate_keypair, make_identity, make_recipient};
use crate::{compute_mac, decode_mac_key, decrypt_vault, encrypt_value, generate_mac_key, types};
struct Party {
pubkey: String,
identity: MurkIdentity,
}
impl Party {
fn recipient(&self) -> MurkRecipient {
make_recipient(&self.pubkey)
}
}
fn make_party() -> Party {
let (secret, pubkey) = generate_keypair();
Party {
identity: make_identity(&secret),
pubkey,
}
}
fn recipients_of(parties: &[Party]) -> Vec<MurkRecipient> {
parties.iter().map(Party::recipient).collect()
}
fn build_vault(
parties: &[Party],
shared: &BTreeMap<String, String>,
scoped: &BTreeMap<String, (usize, String)>,
) -> types::Vault {
let all = recipients_of(parties);
let mut vault = empty_vault();
vault.recipients = parties.iter().map(|p| p.pubkey.clone()).collect();
for (key, val) in shared {
vault.secrets.insert(
key.clone(),
types::SecretEntry {
shared: encrypt_value(val.as_bytes(), &all).unwrap(),
private: BTreeMap::new(),
grouped: BTreeMap::new(),
},
);
}
for (key, (idx, val)) in scoped {
let target = &parties[*idx];
let mut private = BTreeMap::new();
private.insert(
target.pubkey.clone(),
encrypt_value(val.as_bytes(), std::slice::from_ref(&target.recipient())).unwrap(),
);
vault.secrets.insert(
key.clone(),
types::SecretEntry {
shared: String::new(),
private,
grouped: BTreeMap::new(),
},
);
}
seal_meta(&mut vault, parties, &all);
vault
}
fn seal_meta(vault: &mut types::Vault, parties: &[Party], all: &[MurkRecipient]) {
let groups = BTreeMap::new();
let grants = BTreeMap::new();
let mac_key_hex = generate_mac_key();
let mac_key = decode_mac_key(&mac_key_hex).unwrap();
let mac = compute_mac(vault, &groups, &grants, Some(&mac_key));
let meta = types::Meta {
recipients: parties
.iter()
.map(|p| (p.pubkey.clone(), "tester".to_string()))
.collect(),
mac,
mac_key: Some(mac_key_hex),
..Default::default()
};
vault.meta = encrypt_value(&serde_json::to_vec(&meta).unwrap(), all).unwrap();
}
fn shared_key() -> &'static str {
"H[A-Z0-9_]{0,8}"
}
fn scoped_key() -> &'static str {
"S[A-Z0-9_]{0,8}"
}
fn value() -> &'static str {
"[a-zA-Z0-9 _.:/=+-]{0,48}"
}
proptest! {
#![proptest_config(ProptestConfig { cases: 128, ..ProptestConfig::default() })]
#[test]
fn roundtrip_every_recipient_reads_shared(
shared in prop::collection::btree_map(shared_key(), value(), 0..6),
n in 1usize..=4,
) {
let parties: Vec<Party> = (0..n).map(|_| make_party()).collect();
let vault = build_vault(&parties, &shared, &BTreeMap::new());
for p in &parties {
let murk = decrypt_vault(&vault, &p.identity)
.expect("a listed recipient must decrypt an intact vault");
prop_assert_eq!(murk.values.len(), shared.len());
for (k, v) in &shared {
let got = murk.values.get(k).map(|z| z.as_str());
prop_assert_eq!(got, Some(v.as_str()));
}
}
let outsider = make_party();
prop_assert!(decrypt_vault(&vault, &outsider.identity).is_err());
}
#[test]
fn any_tamper_fails_the_load(
shared in prop::collection::btree_map(shared_key(), value(), 1..6),
n in 1usize..=4,
sel in 0u8..6,
) {
let parties: Vec<Party> = (0..n).map(|_| make_party()).collect();
let mut vault = build_vault(&parties, &shared, &BTreeMap::new());
prop_assert!(decrypt_vault(&vault, &parties[0].identity).is_ok());
let first_key = vault.secrets.keys().next().unwrap().clone();
match sel {
0 => {
let all = recipients_of(&parties);
vault.secrets.get_mut(&first_key).unwrap().shared =
encrypt_value(b"POISON-DIFFERENT-VALUE", &all).unwrap();
}
1 => vault.recipients.push(
"age1injected000000000000000000000000000000000000000000000000000".into(),
),
2 => {
vault.recipients.pop();
}
3 => {
let entry = vault.secrets.remove(&first_key).unwrap();
vault.secrets.insert(format!("{first_key}_RENAMED"), entry);
}
4 => vault.meta.insert(0, 'Z'),
_ => {
vault.schema.insert(
first_key.clone(),
types::SchemaEntry { description: "injected".into(), ..Default::default() },
);
}
}
prop_assert!(
decrypt_vault(&vault, &parties[0].identity).is_err(),
"tampered vault (mutation {sel}) must fail the load"
);
}
#[test]
fn scoped_values_isolated_per_recipient(
shared in prop::collection::btree_map(shared_key(), value(), 0..3),
scoped_spec in prop::collection::btree_map(scoped_key(), (0usize..4, value()), 1..4),
n in 2usize..=4,
) {
let parties: Vec<Party> = (0..n).map(|_| make_party()).collect();
let scoped: BTreeMap<String, (usize, String)> = scoped_spec
.iter()
.map(|(k, (idx, v))| (k.clone(), (idx % n, v.clone())))
.collect();
let vault = build_vault(&parties, &shared, &scoped);
for (r, p) in parties.iter().enumerate() {
let murk = decrypt_vault(&vault, &p.identity).expect("recipient must decrypt");
for k in shared.keys() {
prop_assert!(murk.values.contains_key(k));
}
for k in scoped.keys() {
prop_assert!(!murk.values.contains_key(k), "scoped key {k} leaked to shared");
}
for (key, (target, val)) in &scoped {
let visible = murk
.private
.get(key)
.and_then(|m| m.get(&p.pubkey))
.map(|z| z.as_str());
if *target == r {
prop_assert_eq!(visible, Some(val.as_str()));
} else {
prop_assert!(
!murk.private.contains_key(key),
"recipient {r} must not see scoped key {key} addressed to {target}"
);
}
}
}
}
}
fn seal_with_grants(
vault: &mut types::Vault,
names: &[(String, String)],
all: &[MurkRecipient],
grants: &BTreeMap<String, types::GrantEntry>,
) {
let groups = BTreeMap::new();
let mac_key_hex = generate_mac_key();
let mac_key = decode_mac_key(&mac_key_hex).unwrap();
let mac = compute_mac(vault, &groups, grants, Some(&mac_key));
let meta = types::Meta {
recipients: names.iter().cloned().collect(),
mac,
mac_key: Some(mac_key_hex),
grants: grants.clone(),
..Default::default()
};
vault.meta = encrypt_value(&serde_json::to_vec(&meta).unwrap(), all).unwrap();
}
fn secret_entry(shared: String, private: BTreeMap<String, String>) -> types::SecretEntry {
types::SecretEntry {
shared,
private,
grouped: BTreeMap::new(),
}
}
#[test]
fn agent_grant_reads_only_scoped_never_shared() {
let op = make_party(); let agent = make_party(); let op_rec = op.recipient();
let agent_rec = agent.recipient();
let mut vault = empty_vault();
vault.recipients = vec![op.pubkey.clone(), agent.pubkey.clone()];
vault.secrets.insert(
"SHARED".into(),
secret_entry(
encrypt_value(b"op-only-shared", std::slice::from_ref(&op_rec)).unwrap(),
BTreeMap::new(),
),
);
let mut granted = BTreeMap::new();
granted.insert(
agent.pubkey.clone(),
encrypt_value(b"granted-value", std::slice::from_ref(&agent_rec)).unwrap(),
);
vault.secrets.insert(
"GRANTED".into(),
secret_entry(
encrypt_value(b"granted-value", std::slice::from_ref(&op_rec)).unwrap(),
granted,
),
);
let mut grants = BTreeMap::new();
grants.insert(
"build-bot".to_string(),
types::GrantEntry {
pubkey: agent.pubkey.clone(),
scope: vec!["GRANTED".into()],
issued_at: "2026-02-28T00:00:00Z".into(),
expires_at: "2026-02-28T02:00:00Z".into(),
issuer: op.pubkey.clone(),
},
);
let all = [op_rec, agent_rec];
seal_with_grants(
&mut vault,
&[
(op.pubkey.clone(), "op".into()),
(agent.pubkey.clone(), "build-bot".into()),
],
&all,
&grants,
);
let op_view = decrypt_vault(&vault, &op.identity).expect("operator decrypts");
assert_eq!(
op_view.values.get("SHARED").map(|z| z.as_str()),
Some("op-only-shared")
);
assert_eq!(
op_view.values.get("GRANTED").map(|z| z.as_str()),
Some("granted-value")
);
let agent_view = decrypt_vault(&vault, &agent.identity)
.expect("agent is a meta recipient + grant, so it decrypts");
assert!(
agent_view.values.is_empty(),
"agent must not read any shared secret"
);
assert_eq!(
agent_view
.private
.get("GRANTED")
.and_then(|m| m.get(&agent.pubkey))
.map(|z| z.as_str()),
Some("granted-value"),
"agent must read exactly its granted, scoped key"
);
let mut schema_tampered = vault.clone();
schema_tampered.schema.insert(
"GRANTED".into(),
types::SchemaEntry {
description: "injected".into(),
..Default::default()
},
);
assert!(
decrypt_vault(&schema_tampered, &op.identity).is_err(),
"schema tamper on a v7 vault must fail the load"
);
}
#[test]
fn listed_recipient_excluded_from_shared_fails_load() {
let a = make_party();
let b = make_party();
let a_rec = a.recipient();
let b_rec = b.recipient();
let mut vault = empty_vault();
vault.recipients = vec![a.pubkey.clone(), b.pubkey.clone()];
vault.secrets.insert(
"S".into(),
secret_entry(
encrypt_value(b"b-only", std::slice::from_ref(&b_rec)).unwrap(),
BTreeMap::new(),
),
);
let all = [a_rec, b_rec];
seal_with_grants(
&mut vault,
&[
(a.pubkey.clone(), "a".into()),
(b.pubkey.clone(), "b".into()),
],
&all,
&BTreeMap::new(),
);
assert!(
decrypt_vault(&vault, &b.identity).is_ok(),
"B is a recipient of S"
);
assert!(
decrypt_vault(&vault, &a.identity).is_err(),
"a listed non-agent recipient that cannot read a shared value must fail the load"
);
}
#[test]
fn legacy_unkeyed_mac_vault_sets_legacy_flag() {
let p = make_party();
let rec = p.recipient();
let mut vault = empty_vault();
vault.recipients = vec![p.pubkey.clone()];
vault.secrets.insert(
"K".into(),
secret_entry(
encrypt_value(b"v", std::slice::from_ref(&rec)).unwrap(),
BTreeMap::new(),
),
);
let mac = compute_mac(&vault, &BTreeMap::new(), &BTreeMap::new(), None);
let meta = types::Meta {
recipients: [(p.pubkey.clone(), "p".to_string())].into_iter().collect(),
mac,
mac_key: None,
..Default::default()
};
vault.meta = encrypt_value(
&serde_json::to_vec(&meta).unwrap(),
std::slice::from_ref(&rec),
)
.unwrap();
let murk = decrypt_vault(&vault, &p.identity).expect("legacy-MAC vault still loads");
assert!(
murk.legacy_mac,
"an unkeyed sha256 MAC must be flagged as legacy"
);
}
#[test]
fn mac_downgrade_below_group_scheme_rejected() {
let p = make_party();
let rec = p.recipient();
let mut vault = empty_vault();
vault.recipients = vec![p.pubkey.clone()];
let mut grouped = BTreeMap::new();
grouped.insert(
"team".to_string(),
encrypt_value(b"group-val", std::slice::from_ref(&rec)).unwrap(),
);
vault.secrets.insert(
"K".into(),
types::SecretEntry {
shared: encrypt_value(b"v", std::slice::from_ref(&rec)).unwrap(),
private: BTreeMap::new(),
grouped,
},
);
let mac_key_hex = generate_mac_key();
let mac_key = decode_mac_key(&mac_key_hex).unwrap();
let downgraded = crate::compute_mac_v5(&vault, &mac_key);
let meta = types::Meta {
recipients: [(p.pubkey.clone(), "p".to_string())].into_iter().collect(),
mac: downgraded,
mac_key: Some(mac_key_hex),
..Default::default()
};
vault.meta = encrypt_value(
&serde_json::to_vec(&meta).unwrap(),
std::slice::from_ref(&rec),
)
.unwrap();
assert!(
decrypt_vault(&vault, &p.identity).is_err(),
"a grouped vault stamped with a pre-group MAC must be rejected as a downgrade"
);
}