use crate::operational::SeenDocuments;
use std::{collections::HashMap, sync::Arc};
use crate::{
agent_name::CachedAgentName,
config::account::Account,
config::secured_config::{unlock_code_decrypt, unlock_code_encrypt},
errors::OpenVTCError,
logs::{LogFamily, Logs},
relationships::Relationships,
tasks::Tasks,
tsp_store::TspRelationships,
vetting::VettingBook,
vrc::Vrcs,
};
use affinidi_tdk::TDK;
use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD};
use ed25519_dalek_bip32::{DerivationPath, ExtendedSigningKey};
use secrecy::{ExposeSecret, SecretBox};
use serde::{Deserialize, Serialize};
use tracing::{debug, error, warn};
pub const PROTECTED_SCHEMA_VERSION: u32 = 1;
fn default_protected_schema_version() -> u32 {
1
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct DeferredInbound {
pub sender: String,
pub message: affinidi_tdk::didcomm::Message,
pub transport: crate::didcomm::MessagingTransport,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Contact {
pub did: Arc<String>,
pub alias: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(from = "ContactsShadow")]
pub struct Contacts {
pub contacts: HashMap<Arc<String>, Arc<Contact>>,
#[serde(skip)]
pub aliases: HashMap<String, Arc<Contact>>,
}
impl Contacts {
pub fn is_empty(&self) -> bool {
self.contacts.is_empty()
}
pub async fn add_contact(
&mut self,
tdk: &TDK,
contact_did: &str,
alias: Option<String>,
check_did: bool,
logs: &mut Logs,
) -> Result<Arc<Contact>, OpenVTCError> {
if check_did {
match tdk.did_resolver().resolve(contact_did).await {
Ok(_) => {}
Err(e) => {
error!("Couldn't resolve DID ({contact_did}). Reason: {e}");
return Err(OpenVTCError::Resolver(format!(
"Couldn't resolve DID ({}). Reason: {}",
contact_did, e
)));
}
}
}
let contact_did = Arc::new(contact_did.to_string());
if let Some(alias) = &alias
&& self.aliases.contains_key(alias)
{
warn!("Duplicate alias ({alias}) detected! Existing alias must be removed first!");
return Err(OpenVTCError::Contact(format!(
"Duplicate alias ({alias}) detected! Existing alias must be removed first!"
)));
}
let contact = Arc::new(Contact {
did: contact_did.clone(),
alias: alias.clone(),
});
self.contacts.insert(contact_did.clone(), contact.clone());
if let Some(alias) = &alias {
self.aliases.insert(alias.clone(), contact.clone());
}
logs.insert(
LogFamily::Contact,
format!(
"Added contact ({}) alias({})",
contact_did,
alias.as_deref().unwrap_or("N/A")
),
);
Ok(contact)
}
pub fn remove_contact(&mut self, logs: &mut Logs, id: &str) -> Option<Arc<Contact>> {
if let Some(contact) = self.find_contact(id) {
if let Some(alias) = &contact.alias {
self.aliases.remove(alias);
}
let result = self.contacts.remove(&contact.did);
if result.is_some() {
logs.insert(
LogFamily::Contact,
format!(
"Removed contact ({}) alias({})",
contact.did,
contact.alias.as_deref().unwrap_or("N/A")
),
);
}
result
} else {
None
}
}
pub fn find_contact(&self, id: &str) -> Option<Arc<Contact>> {
if let Some(contact) = self.aliases.get(id) {
Some(contact.clone())
} else {
#[allow(clippy::unnecessary_to_owned)] self.contacts.get(&(id.to_string())).cloned()
}
}
}
#[derive(Deserialize)]
struct ContactsShadow {
contacts: HashMap<Arc<String>, Arc<Contact>>,
}
impl From<ContactsShadow> for Contacts {
fn from(shadow: ContactsShadow) -> Self {
let mut contacts = Contacts {
contacts: shadow.contacts,
aliases: HashMap::new(),
};
for contact in contacts.contacts.values() {
if let Some(alias) = &contact.alias {
contacts.aliases.insert(alias.clone(), contact.clone());
}
}
contacts
}
}
#[derive(Clone, Serialize, Deserialize, Debug)]
pub struct ProtectedConfig {
#[serde(default = "default_protected_schema_version")]
pub schema_version: u32,
#[serde(default)]
pub account: Account,
pub contacts: Contacts,
#[serde(default)]
pub relationships: Relationships,
#[serde(default, skip_serializing_if = "TspRelationships::is_empty")]
pub tsp_relationships: TspRelationships,
#[serde(default)]
pub tasks: Tasks,
pub vrcs_issued: Vrcs,
pub vrcs_received: Vrcs,
#[serde(default)]
pub agent_names: HashMap<String, CachedAgentName>,
#[serde(default, skip_serializing_if = "VettingBook::is_empty")]
pub vetting: VettingBook,
#[serde(default, skip_serializing_if = "SeenDocuments::is_empty")]
pub seen_documents: SeenDocuments,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deferred_inbound: Vec<DeferredInbound>,
#[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
pub extra: serde_json::Map<String, serde_json::Value>,
}
impl ProtectedConfig {
#[must_use]
pub fn cached_agent_name(&self, did: &str) -> Option<&str> {
self.agent_names.get(did).and_then(|c| c.name.as_deref())
}
pub fn prune_agent_name_negatives(&mut self) -> usize {
let before = self.agent_names.len();
self.agent_names.retain(|_, cached| cached.name.is_some());
before - self.agent_names.len()
}
}
impl Default for ProtectedConfig {
fn default() -> Self {
ProtectedConfig {
extra: serde_json::Map::new(),
schema_version: PROTECTED_SCHEMA_VERSION,
account: Account::default(),
contacts: Contacts::default(),
relationships: Relationships::default(),
tsp_relationships: TspRelationships::default(),
tasks: Tasks::default(),
vrcs_issued: Vrcs::default(),
vrcs_received: Vrcs::default(),
agent_names: HashMap::default(),
vetting: VettingBook::default(),
seen_documents: SeenDocuments::default(),
deferred_inbound: Vec::new(),
}
}
}
impl ProtectedConfig {
pub fn save(&self, seed_bytes: &SecretBox<Vec<u8>>) -> Result<String, OpenVTCError> {
let bytes = serde_json::to_vec(self)?;
match unlock_code_encrypt(
seed_bytes
.expose_secret()
.first_chunk::<32>()
.ok_or_else(|| {
OpenVTCError::Encrypt("Seed bytes are not at least 32 bytes".to_string())
})?,
&bytes,
) {
Ok(result) => Ok(BASE64_URL_SAFE_NO_PAD.encode(&result)),
Err(e) => Err(e),
}
}
pub fn load(
seed_bytes: &SecretBox<Vec<u8>>,
input: &str,
) -> Result<ProtectedConfig, OpenVTCError> {
let bytes = BASE64_URL_SAFE_NO_PAD.decode(input)?;
let bytes = unlock_code_decrypt(
seed_bytes
.expose_secret()
.first_chunk::<32>()
.ok_or_else(|| {
OpenVTCError::Decrypt("Seed bytes are not at least 32 bytes".to_string())
})?,
&bytes,
)?;
Self::parse(&bytes)
}
pub fn parse(plaintext: &[u8]) -> Result<ProtectedConfig, OpenVTCError> {
let mut config: ProtectedConfig = serde_json::from_slice(plaintext)?;
config.vetting.retire_nonconformant();
Ok(config)
}
pub fn get_seed(
bip32: &ExtendedSigningKey,
path: &str,
) -> Result<SecretBox<Vec<u8>>, OpenVTCError> {
let derived = bip32
.derive(&path.parse::<DerivationPath>().map_err(|e| {
OpenVTCError::BIP32(format!("Couldn't parse derivation path ({}): {}", path, e))
})?)
.map_err(|e| {
OpenVTCError::BIP32(format!(
"Couldn't derive secret key for path ({}): {}",
path, e
))
})?;
Ok(SecretBox::new(Box::new(
derived.signing_key.as_bytes().to_vec(),
)))
}
pub fn get_seed_legacy(
bip32: &ExtendedSigningKey,
path: &str,
) -> Result<SecretBox<Vec<u8>>, OpenVTCError> {
let derived = bip32
.derive(&path.parse::<DerivationPath>().map_err(|e| {
OpenVTCError::BIP32(format!("Couldn't parse derivation path ({}): {}", path, e))
})?)
.map_err(|e| {
OpenVTCError::BIP32(format!(
"Couldn't derive secret key for path ({}): {}",
path, e
))
})?;
Ok(SecretBox::new(Box::new(
derived.verifying_key().to_bytes().to_vec(),
)))
}
pub fn get_seed_from_credential(
private_key_multibase: &str,
) -> Result<SecretBox<Vec<u8>>, OpenVTCError> {
use hkdf::Hkdf;
use sha2::Sha256;
debug!("deriving encryption seed from credential via HKDF");
let hk = Hkdf::<Sha256>::new(None, private_key_multibase.as_bytes());
let mut seed = vec![0u8; 32];
hk.expand(b"openvtc-protected-config-seed-v1", &mut seed)
.map_err(|e| {
OpenVTCError::Encrypt(format!("HKDF expansion failed for credential seed: {e}"))
})?;
Ok(SecretBox::new(Box::new(seed)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_accepts_plaintext_json_and_rejects_garbage() {
let bytes = serde_json::to_vec(&ProtectedConfig::default()).unwrap();
assert!(ProtectedConfig::parse(&bytes).is_ok());
assert!(ProtectedConfig::parse(b"not json at all").is_err());
assert!(ProtectedConfig::parse(&[]).is_err());
}
#[test]
fn parse_tolerates_config_without_agent_names() {
let mut value = serde_json::to_value(ProtectedConfig::default()).unwrap();
value
.as_object_mut()
.unwrap()
.remove("agent_names")
.expect("current config serialises agent_names");
let bytes = serde_json::to_vec(&value).unwrap();
let parsed = ProtectedConfig::parse(&bytes).expect("legacy config must load");
assert!(parsed.agent_names.is_empty());
}
fn test_seed() -> SecretBox<Vec<u8>> {
SecretBox::new(Box::new(vec![42u8; 32]))
}
#[test]
fn test_protected_config_save_load_roundtrip() {
let config = ProtectedConfig::default();
let seed = test_seed();
let saved = config.save(&seed).unwrap();
assert!(!saved.is_empty());
let loaded = ProtectedConfig::load(&seed, &saved).unwrap();
assert!(loaded.contacts.is_empty());
}
#[test]
fn test_protected_config_account_round_trips() {
use crate::config::account::{Account, PersonaId, PersonaRecord};
use chrono::Utc;
let mut config = ProtectedConfig::default();
let pid = PersonaId::new();
config.account = Account {
vta_did: "did:webvh:vta.example".into(),
vta_url: "https://vta.example".into(),
top_context_id: "openvtc".into(),
..Account::default()
};
config.account.personas.insert(
pid,
PersonaRecord {
extra: serde_json::Map::new(),
persona_id: pid,
did: "did:webvh:example:p".into(),
did_document: None,
key_refs: Vec::new(),
mediator_did: Some("did:webvh:mediator".into()),
origin_context_id: String::new(),
created_at: Utc::now(),
label: Some("alice".into()),
},
);
let seed = test_seed();
let saved = config.save(&seed).unwrap();
let loaded = ProtectedConfig::load(&seed, &saved).unwrap();
assert_eq!(loaded.account.vta_did, "did:webvh:vta.example");
assert_eq!(loaded.account.personas.len(), 1);
assert_eq!(loaded.account.personas[&pid].did, "did:webvh:example:p");
}
#[test]
fn test_protected_config_wrong_seed_fails() {
let config = ProtectedConfig::default();
let seed = test_seed();
let wrong_seed = SecretBox::new(Box::new(vec![99u8; 32]));
let saved = config.save(&seed).unwrap();
let result = ProtectedConfig::load(&wrong_seed, &saved);
assert!(result.is_err());
}
#[test]
fn test_protected_config_serialization_preserves_data() {
let config = ProtectedConfig::default();
let json = serde_json::to_string(&config).unwrap();
let deserialized: ProtectedConfig = serde_json::from_str(&json).unwrap();
assert!(deserialized.contacts.is_empty());
}
#[test]
fn test_schema_version_defaults_to_one_when_absent() {
let json = r#"{
"contacts": { "contacts": {} },
"vrcs_issued": { "vrcs": {} },
"vrcs_received": { "vrcs": {} }
}"#;
let config: ProtectedConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.schema_version, 1);
}
#[test]
fn test_schema_version_round_trips() {
let config = ProtectedConfig::default();
assert_eq!(config.schema_version, PROTECTED_SCHEMA_VERSION);
let json = serde_json::to_string(&config).unwrap();
assert!(json.contains("\"schema_version\":"));
let deserialized: ProtectedConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.schema_version, PROTECTED_SCHEMA_VERSION);
let seed = test_seed();
let saved = config.save(&seed).unwrap();
let loaded = ProtectedConfig::load(&seed, &saved).unwrap();
assert_eq!(loaded.schema_version, PROTECTED_SCHEMA_VERSION);
}
#[test]
fn test_contacts_find_by_did() {
let mut contacts = Contacts::default();
let did = Arc::new("did:example:123".to_string());
let contact = Arc::new(Contact {
did: did.clone(),
alias: Some("alice".to_string()),
});
contacts.contacts.insert(did.clone(), contact.clone());
contacts
.aliases
.insert("alice".to_string(), contact.clone());
assert!(contacts.find_contact("did:example:123").is_some());
assert!(contacts.find_contact("alice").is_some());
assert!(contacts.find_contact("unknown").is_none());
}
#[test]
fn test_contacts_remove() {
let mut contacts = Contacts::default();
let did = Arc::new("did:example:123".to_string());
let contact = Arc::new(Contact {
did: did.clone(),
alias: Some("bob".to_string()),
});
contacts.contacts.insert(did.clone(), contact.clone());
contacts.aliases.insert("bob".to_string(), contact.clone());
let mut logs = Logs::default();
let removed = contacts.remove_contact(&mut logs, "bob");
assert!(removed.is_some());
assert!(contacts.find_contact("bob").is_none());
assert!(contacts.find_contact("did:example:123").is_none());
}
#[test]
fn test_get_seed_from_credential_deterministic() {
let key = "z6MkTestKey123";
let seed1 = ProtectedConfig::get_seed_from_credential(key).unwrap();
let seed2 = ProtectedConfig::get_seed_from_credential(key).unwrap();
assert_eq!(seed1.expose_secret(), seed2.expose_secret(),);
}
#[test]
fn test_get_seed_from_credential_different_keys_differ() {
let seed1 = ProtectedConfig::get_seed_from_credential("key1").unwrap();
let seed2 = ProtectedConfig::get_seed_from_credential("key2").unwrap();
assert_ne!(seed1.expose_secret(), seed2.expose_secret(),);
}
#[test]
fn pruning_drops_negatives_and_keeps_verified_names() {
use crate::agent_name::CachedAgentName;
let now = chrono::Utc::now();
let mut cfg = ProtectedConfig::default();
cfg.agent_names.insert(
"did:webvh:us:example.com".to_string(),
CachedAgentName {
name: Some("example.com/@alice".to_string()),
checked_at: now,
},
);
cfg.agent_names.insert(
"did:webvh:us:nameless.example".to_string(),
CachedAgentName {
name: None,
checked_at: now,
},
);
assert_eq!(cfg.prune_agent_name_negatives(), 1);
assert_eq!(cfg.agent_names.len(), 1);
assert!(cfg.agent_names.contains_key("did:webvh:us:example.com"));
assert!(
!cfg.agent_names
.contains_key("did:webvh:us:nameless.example")
);
assert_eq!(cfg.prune_agent_name_negatives(), 0);
}
}