use std::collections::BTreeMap;
pub struct EncryptedExtFields<'a> {
map: &'a BTreeMap<String, String>,
}
impl<'a> EncryptedExtFields<'a> {
pub fn from_map(map: &'a BTreeMap<String, String>) -> Self {
EncryptedExtFields { map }
}
pub fn pbkdf(&self) -> Option<&str> {
self.map.get("pbkdf").map(|s| s.as_str())
}
pub fn cipher(&self) -> Option<&str> {
self.map.get("cipher").map(|s| s.as_str())
}
pub fn is_kem_mode(&self) -> bool {
self.map.contains_key("recipients")
}
pub fn recipients(&self) -> Option<&str> {
self.map.get("recipients").map(|s| s.as_str())
}
pub fn recipient_ct(&self, fp_hex: &str) -> Option<&str> {
self.map
.get(&format!("recipient-mlkem-{}", fp_hex))
.map(|s| s.as_str())
}
pub fn raw(&self) -> &BTreeMap<String, String> {
self.map
}
}
pub struct AnchorExtFields<'a> {
map: &'a BTreeMap<String, String>,
}
impl<'a> AnchorExtFields<'a> {
pub fn from_map(map: &'a BTreeMap<String, String>) -> Self {
AnchorExtFields { map }
}
pub fn signer(&self) -> Option<&str> {
self.map.get("signer").map(|s| s.as_str())
}
pub fn signers(&self) -> Option<&str> {
self.map.get("signers").map(|s| s.as_str())
}
pub fn payload(&self) -> Option<&str> {
self.map.get("payload").map(|s| s.as_str())
}
pub fn sig(&self) -> Option<&str> {
self.map.get("sig").map(|s| s.as_str())
}
pub fn sigs(&self) -> Option<&str> {
self.map.get("sigs").map(|s| s.as_str())
}
pub fn parents(&self) -> Option<&str> {
self.map.get("parents").map(|s| s.as_str())
}
pub fn timestamp(&self) -> Option<&str> {
self.map.get("ts").map(|s| s.as_str())
}
pub fn mutations(&self) -> Option<&str> {
self.map.get("mut").map(|s| s.as_str())
}
pub fn is_multi_sig(&self) -> bool {
self.map.contains_key("signers")
}
pub fn raw(&self) -> &BTreeMap<String, String> {
self.map
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum EncryptedExtField {
Pbkdf(String),
Cipher(String),
Recipients(String),
RecipientMlKemCt { fp_hex: String, ct_base64: String },
Attribute(String),
Unknown { key: String, value: String },
}
impl EncryptedExtField {
pub fn into_entry(self) -> (String, String) {
match self {
EncryptedExtField::Pbkdf(v) => ("pbkdf".to_string(), v),
EncryptedExtField::Cipher(v) => ("cipher".to_string(), v),
EncryptedExtField::Recipients(v) => ("recipients".to_string(), v),
EncryptedExtField::RecipientMlKemCt { fp_hex, ct_base64 } => {
(format!("recipient-mlkem-{}", fp_hex), ct_base64)
}
EncryptedExtField::Attribute(v) => ("attr".to_string(), v),
EncryptedExtField::Unknown { key, value } => (key, value),
}
}
pub fn from_entry(key: &str, value: &str) -> Self {
match key {
"pbkdf" => EncryptedExtField::Pbkdf(value.to_string()),
"cipher" => EncryptedExtField::Cipher(value.to_string()),
"recipients" => EncryptedExtField::Recipients(value.to_string()),
"attr" => EncryptedExtField::Attribute(value.to_string()),
other if other.starts_with("recipient-mlkem-") => EncryptedExtField::RecipientMlKemCt {
fp_hex: other["recipient-mlkem-".len()..].to_string(),
ct_base64: value.to_string(),
},
_ => EncryptedExtField::Unknown {
key: key.to_string(),
value: value.to_string(),
},
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AnchorExtField {
Signer(String),
Signers(String),
Payload(String),
Sig(String),
Sigs(String),
Parents(String),
Timestamp(String),
Mutation(String),
Unknown { key: String, value: String },
}
impl AnchorExtField {
pub fn into_entry(self) -> (String, String) {
match self {
AnchorExtField::Signer(v) => ("signer".to_string(), v),
AnchorExtField::Signers(v) => ("signers".to_string(), v),
AnchorExtField::Payload(v) => ("payload".to_string(), v),
AnchorExtField::Sig(v) => ("sig".to_string(), v),
AnchorExtField::Sigs(v) => ("sigs".to_string(), v),
AnchorExtField::Parents(v) => ("parents".to_string(), v),
AnchorExtField::Timestamp(v) => ("ts".to_string(), v),
AnchorExtField::Mutation(v) => ("mut".to_string(), v),
AnchorExtField::Unknown { key, value } => (key, value),
}
}
pub fn from_entry(key: &str, value: &str) -> Self {
match key {
"signer" => AnchorExtField::Signer(value.to_string()),
"signers" => AnchorExtField::Signers(value.to_string()),
"payload" => AnchorExtField::Payload(value.to_string()),
"sig" => AnchorExtField::Sig(value.to_string()),
"sigs" => AnchorExtField::Sigs(value.to_string()),
"parents" => AnchorExtField::Parents(value.to_string()),
"ts" => AnchorExtField::Timestamp(value.to_string()),
"mut" => AnchorExtField::Mutation(value.to_string()),
_ => AnchorExtField::Unknown {
key: key.to_string(),
value: value.to_string(),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn encrypted_extfields_reads_known_fields() {
let m = map(&[
("pbkdf", "$argon2$m=65536,t=3,p=4$salt$"),
("cipher", "aes-256-gcm$iv=MDEyMzQ1Njc4OWFiY2RlZg=="),
]);
let ef = EncryptedExtFields::from_map(&m);
assert!(ef.pbkdf().is_some());
assert!(ef.cipher().is_some());
assert!(!ef.is_kem_mode());
assert!(ef.recipients().is_none());
}
#[test]
fn encrypted_extfields_detects_kem_mode() {
let m = map(&[
("recipients", "mlkem:1c8d2e...,mlkem:9f3a7b..."),
("recipient-mlkem-1c8d2e...", "YmFzZTY0Cg=="),
]);
let ef = EncryptedExtFields::from_map(&m);
assert!(ef.is_kem_mode());
assert!(ef.pbkdf().is_none());
assert!(ef.recipients().is_some());
assert!(ef.recipient_ct("1c8d2e...").is_some());
assert!(ef.recipient_ct("9f3a7b...").is_none()); }
#[test]
fn anchor_extfields_reads_single_and_multi_sig() {
let single = map(&[("signer", "ed25519:abc..."), ("sig", "deadbeef...")]);
let multi = map(&[
("signer", "ed25519:abc..."),
("sig", "deadbeef..."),
("signers", "ed25519:abc...,ed25519:def..."),
("sigs", "deadbeef...,cafebabe..."),
]);
let sef = AnchorExtFields::from_map(&single);
assert!(!sef.is_multi_sig());
assert!(sef.signer().is_some());
let mef = AnchorExtFields::from_map(&multi);
assert!(mef.is_multi_sig());
assert!(mef.signers().is_some());
assert!(mef.sigs().is_some());
}
#[test]
fn encrypted_extfield_round_trips_through_entry() {
for original in [
EncryptedExtField::Pbkdf("$argon2id$v=19$m=65536,t=3,p=4$c2FsdA$hash".to_string()),
EncryptedExtField::Cipher("aes-256-gcm$iv=MDEy".to_string()),
EncryptedExtField::Recipients("mlkem:abc...,mlkem:def...".to_string()),
EncryptedExtField::RecipientMlKemCt {
fp_hex: "abc...".to_string(),
ct_base64: "YmFzZTY0Cg==".to_string(),
},
EncryptedExtField::Attribute("clearance%3E%3DSECRET".to_string()),
EncryptedExtField::Unknown {
key: "x-future-field".to_string(),
value: "preserved".to_string(),
},
] {
let (k, v) = original.clone().into_entry();
let recovered = EncryptedExtField::from_entry(&k, &v);
assert_eq!(original, recovered);
}
}
#[test]
fn anchor_extfield_round_trips_through_entry() {
for original in [
AnchorExtField::Signer("ed25519:9f3a7b...".to_string()),
AnchorExtField::Signers("ed25519:abc...,ed25519:def...".to_string()),
AnchorExtField::Payload("a3f5...".to_string()),
AnchorExtField::Sig("deadbeef...".to_string()),
AnchorExtField::Sigs("deadbeef...,cafebabe...".to_string()),
AnchorExtField::Parents("a3f5...,b2c4...".to_string()),
AnchorExtField::Timestamp("20260728T143000Z".to_string()),
AnchorExtField::Mutation("encrypted+signed".to_string()),
AnchorExtField::Unknown {
key: "x-future".to_string(),
value: "v".to_string(),
},
] {
let (k, v) = original.clone().into_entry();
let recovered = AnchorExtField::from_entry(&k, &v);
assert_eq!(original, recovered);
}
}
#[test]
fn typed_extfield_collects_into_btreemap() {
let fields = vec![
AnchorExtField::Signer("ed25519:abc".to_string()),
AnchorExtField::Sig("deadbeef".to_string()),
AnchorExtField::Payload("a3f5".to_string()),
];
let wire: BTreeMap<String, String> =
fields.into_iter().map(AnchorExtField::into_entry).collect();
assert_eq!(wire.get("signer").map(|s| s.as_str()), Some("ed25519:abc"));
assert_eq!(wire.get("sig").map(|s| s.as_str()), Some("deadbeef"));
assert_eq!(wire.get("payload").map(|s| s.as_str()), Some("a3f5"));
}
}