use crate::{
config::{
Config, ConfigProtectionType, KeyBackend, UnlockCode,
account::{Account, PersonaId},
integrity::{DegradedPersona, DegradedReason, LoadIntegrity, StrandedMembership},
protected_config::ProtectedConfig,
public_config::PublicConfig,
secured_config::SecuredConfig,
},
errors::OpenVTCError,
};
use affinidi_tdk::{TDK, messaging::profiles::ATMProfile};
use base64::{Engine, prelude::BASE64_URL_SAFE_NO_PAD};
use ed25519_dalek_bip32::ExtendedSigningKey;
use secrecy::{ExposeSecret, SecretBox, SecretString};
use std::collections::BTreeMap;
use tracing::{debug, info, warn};
use vta_sdk::credentials::CredentialBundle;
#[cfg(feature = "openpgp-card")]
use super::TokenInteractions;
pub type ProgressFn<'a> = &'a (dyn Fn(&str, &str) + Send + Sync);
fn decrypt_protected_with_fallback(
stored: Option<&SecretBox<Vec<u8>>>,
legacy: &SecretBox<Vec<u8>>,
oldest: Option<&SecretBox<Vec<u8>>>,
blob: &str,
) -> Result<(ProtectedConfig, bool), OpenVTCError> {
if let Some(seed) = stored {
match ProtectedConfig::load(seed, blob) {
Ok(cfg) => return Ok((cfg, false)),
Err(e) => {
debug!("stored config key did not decrypt ({e}); trying older seeds");
}
}
}
match ProtectedConfig::load(legacy, blob) {
Ok(cfg) => {
if stored.is_none() {
info!("config predates its own encryption key; re-keying on next save (D12)");
} else {
warn!(
"config decrypted with the pre-D12 seed despite a stored key — a previous \
re-key was interrupted; completing it on next save"
);
}
Ok((cfg, true))
}
Err(legacy_err) => {
let Some(oldest) = oldest else {
return Err(legacy_err);
};
match ProtectedConfig::load(oldest, blob) {
Ok(cfg) => {
warn!(
"config was encrypted with the pre-0.1.4 seed — re-encrypting on next save"
);
Ok((cfg, true))
}
Err(e) => Err(e),
}
}
}
}
fn record_degraded(
integrity: &mut LoadIntegrity,
account: &Account,
persona_id: PersonaId,
persona_did: &str,
reason: DegradedReason,
) {
let record = account.personas.get(&persona_id);
warn!(
persona = %persona_did,
"persona unavailable this session: {}", reason.summary()
);
integrity.degraded_personas.push(DegradedPersona {
persona_id,
did: persona_did.to_string(),
label: record.and_then(|p| p.label.clone()),
created_at: record.map_or_else(chrono::Utc::now, |p| p.created_at),
reason,
});
}
fn cross_check(integrity: &mut LoadIntegrity, account: &Account, key_ids: &[String]) {
let unusable: std::collections::HashSet<PersonaId> = integrity
.degraded_personas
.iter()
.map(|p| p.persona_id)
.collect();
for community in account.memberships() {
if unusable.contains(&community.persona_ref) {
integrity.stranded_memberships.push(StrandedMembership {
vtc_did: community.vtc_did.to_string(),
persona_id: community.persona_ref,
label: community.display_name.clone(),
});
}
}
let persona_dids: std::collections::HashSet<&str> =
account.personas.values().map(|p| p.did.as_str()).collect();
for key_id in key_ids {
let did = key_id.split('#').next().unwrap_or(key_id);
if did.starts_with("did:webvh:") && !persona_dids.contains(did) {
integrity.orphaned_key_ids.push(key_id.clone());
}
}
integrity.orphaned_key_ids.sort();
}
impl Config {
pub fn load_step1(profile: &str) -> Result<PublicConfig, OpenVTCError> {
PublicConfig::load(profile)
}
pub async fn load_step2(
tdk: &mut TDK,
profile: &str,
public_config: PublicConfig,
unlock_passphrase: Option<&UnlockCode>,
#[cfg(feature = "openpgp-card")] token_user_pin: &SecretString,
#[cfg(feature = "openpgp-card")] touch_prompt: &impl TokenInteractions,
on_progress: Option<ProgressFn<'_>>,
) -> Result<(Self, Option<vta_sdk::client::VtaClient>), OpenVTCError> {
use tracing::debug;
fn report_progress(on_progress: &Option<ProgressFn<'_>>, major: &str, sub: &str) {
if let Some(f) = on_progress {
f(major, sub);
}
}
report_progress(&on_progress, "Local configuration", "Decrypting secrets");
let mut sc = SecuredConfig::load(
profile,
#[cfg(feature = "openpgp-card")]
token_user_pin,
if let ConfigProtectionType::Token(token) = &public_config.protection {
Some(token)
} else {
None
},
unlock_passphrase,
#[cfg(feature = "openpgp-card")]
touch_prompt,
)?;
debug!(
"Secured Config loaded (key_info entries: {})",
sc.key_info.len()
);
let key_backend = if let Some(ref bip32_seed) = sc.bip32_seed {
let bip32_root = ExtendedSigningKey::from_seed(
BASE64_URL_SAFE_NO_PAD
.decode(bip32_seed.expose_secret())?
.as_slice(),
)
.map_err(|e| {
OpenVTCError::BIP32(format!(
"Couldn't get bip32 root from the secret seed material: {}",
e
))
})?;
KeyBackend::Bip32 {
root: bip32_root,
seed: bip32_seed.clone(),
}
} else if let Some(ref credential_bundle) = sc.credential_bundle {
let bundle: CredentialBundle = serde_json::from_str(credential_bundle.expose_secret())
.map_err(|e| {
OpenVTCError::Config(format!("Couldn't decode VTA credential bundle: {e}"))
})?;
let encryption_seed =
ProtectedConfig::get_seed_from_credential(&bundle.private_key_multibase)?;
KeyBackend::Vta {
credential_bundle: credential_bundle.clone(),
credential_did: bundle.did.clone(),
credential_private_key: SecretString::new(
bundle.private_key_multibase.clone().into(),
),
vta_did: sc.vta_did.clone().unwrap_or_default(),
vta_url: sc.vta_url.clone().unwrap_or_default(),
mediator_did: sc.mediator_did.clone(),
encryption_seed,
}
} else {
return Err(OpenVTCError::Config(
"SecuredConfig has neither bip32_seed nor credential_bundle".to_string(),
));
};
let legacy_seed = match &key_backend {
KeyBackend::Bip32 { root, .. } => ProtectedConfig::get_seed(root, "m/0'/0'/0'")?,
KeyBackend::Vta {
encryption_seed, ..
} => SecretBox::new(Box::new(encryption_seed.expose_secret().to_vec())),
};
let stored_key = sc.protected_key.clone();
let (mut private_cfg, needs_rekey) = match &public_config.private {
Some(blob) => {
let stored_seed = stored_key
.as_ref()
.map(crate::config::decode_protected_key)
.transpose()?;
let oldest_seed = match &key_backend {
KeyBackend::Bip32 { root, .. } => {
Some(ProtectedConfig::get_seed_legacy(root, "m/0'/0'/0'")?)
}
KeyBackend::Vta { .. } => None,
};
decrypt_protected_with_fallback(
stored_seed.as_ref(),
&legacy_seed,
oldest_seed.as_ref(),
blob,
)?
}
None => (ProtectedConfig::default(), stored_key.is_none()),
};
let protected_key = if needs_rekey || stored_key.is_none() {
Some(crate::config::secured_config::new_protected_key())
} else {
stored_key
};
let pruned = private_cfg.prune_agent_name_negatives();
if pruned > 0 {
debug!("Discarded {pruned} cached agent-name negative(s); will re-resolve on launch");
}
log_private_config_shape(&private_cfg);
let mut vta_client = None;
if matches!(&key_backend, KeyBackend::Vta { .. })
&& (!private_cfg.account.personas.is_empty()
|| !private_cfg.account.top_context_id.is_empty())
{
report_progress(&on_progress, "VTA establishment", "Connecting to VTA");
match super::build_runtime_vta_client(&key_backend).await {
Ok(client) => vta_client = Some(client),
Err(e) if private_cfg.account.personas.is_empty() => {
warn!(error = %e, "VTA unreachable; loading without syncing account state");
}
Err(e) => return Err(e),
}
}
if let Some(client) = &vta_client {
report_progress(&on_progress, "VTA establishment", "Syncing account state");
sync_account_state(client, &mut private_cfg, &mut sc.key_info).await;
}
let account = private_cfg.account.clone();
let account_mediator = match &key_backend {
KeyBackend::Vta { mediator_did, .. } => mediator_did.clone().unwrap_or_default(),
KeyBackend::Bip32 { .. } => String::new(),
};
type PersonaSeed = (
crate::config::account::PersonaId,
std::sync::Arc<String>,
String,
Option<affinidi_tdk::did_common::Document>,
);
let personas: Vec<PersonaSeed> = account
.personas
.values()
.map(|p| {
let mediator = {
let stored = p.mediator_did.clone().unwrap_or_default();
if stored.is_empty() {
if account_mediator.is_empty() {
warn!(
persona = %p.did,
"persona has no mediator and the account has none either — \
the persona listener will not connect"
);
} else {
warn!(
persona = %p.did,
"persona stored without a mediator — falling back to the \
account VTA mediator"
);
}
account_mediator.clone()
} else {
stored
}
};
(
p.persona_id,
std::sync::Arc::new(p.did.clone()),
mediator,
p.did_document.clone(),
)
})
.collect();
if vta_client.is_none()
&& matches!(&key_backend, KeyBackend::Vta { .. })
&& !personas.is_empty()
{
report_progress(&on_progress, "VTA establishment", "Connecting to VTA");
vta_client = Some(super::build_runtime_vta_client(&key_backend).await?);
}
let mut identities = BTreeMap::new();
let mut integrity = LoadIntegrity::default();
let hydrate: Result<(), OpenVTCError> = async {
for (persona_id, persona_did, persona_mediator, cached_document) in &personas {
let profile_label = crate::config::membership_profile_label(
account.memberships().find(|c| c.persona_ref == *persona_id),
persona_did,
|did| private_cfg.cached_agent_name(did).map(ToString::to_string),
);
let document = if let Some(doc) = cached_document.clone() {
report_progress(&on_progress, "Identity", "Loading DID document (cached)");
doc
} else {
report_progress(&on_progress, "Identity", "Resolving DID document");
match tdk.did_resolver().resolve(persona_did).await {
Ok(resolved) => resolved.doc,
Err(e) => {
record_degraded(
&mut integrity,
&account,
*persona_id,
persona_did,
DegradedReason::DocumentUnresolvable {
detail: e.to_string(),
},
);
continue;
}
}
};
let persona_mediator = if persona_mediator.is_empty() {
match super::did::mediator_from_document(&document) {
Some(m) => {
warn!(
persona = %persona_did,
mediator = %m,
"recovered persona mediator from its DID document"
);
m
}
None => {
warn!(
persona = %persona_did,
"persona has no mediator anywhere (record, account, \
or DID document) — its listener will not connect"
);
persona_mediator.clone()
}
}
} else {
persona_mediator.clone()
};
report_progress(&on_progress, "Identity", "Fetching persona keys");
if let Err(reason) = Config::regenerate_persona_keys(
tdk,
&sc,
&key_backend,
&document,
vta_client.as_ref(),
)
.await
{
record_degraded(&mut integrity, &account, *persona_id, persona_did, reason);
continue;
}
report_progress(&on_progress, "Identity", "Building messaging profiles");
let atm = tdk.atm.clone().ok_or_else(|| {
OpenVTCError::Config("TDK ATM service not initialized".to_string())
})?;
let persona_profile = match ATMProfile::new(
&atm,
Some(profile_label.clone()),
persona_did.to_string(),
Some(persona_mediator.clone()),
)
.await
{
Ok(profile) => profile,
Err(e) => {
record_degraded(
&mut integrity,
&account,
*persona_id,
persona_did,
DegradedReason::MessagingUnavailable {
detail: e.to_string(),
},
);
continue;
}
};
let persona_profile = match atm.profile_add(&persona_profile, false).await {
Ok(profile) => profile,
Err(e) => {
record_degraded(
&mut integrity,
&account,
*persona_id,
persona_did,
DegradedReason::MessagingUnavailable {
detail: e.to_string(),
},
);
continue;
}
};
identities.insert(
*persona_id,
crate::identity::IdentityContext {
persona_id: *persona_id,
did: persona_did.to_string(),
document,
profile: persona_profile,
mediator_did: Some(persona_mediator.clone()),
},
);
}
let relationship_mediator = identities
.values()
.find_map(|i| i.mediator_did.clone())
.or_else(|| personas.first().map(|(_, _, m, _)| m.clone()));
if let Some(first_mediator) = relationship_mediator.as_ref() {
report_progress(&on_progress, "Identity", "Loading relationships");
let our_p_dids: std::collections::HashSet<String> = personas
.iter()
.map(|(_, did, _, _)| did.to_string())
.collect();
let repair = private_cfg
.relationships
.repair_key_info_ids(tdk, &our_p_dids, &mut sc.key_info, &key_backend)
.await;
if repair.changed() {
info!(
repaired = repair.repaired,
unrecoverable = repair.unrecoverable.len(),
"repaired relationship key ids; config will be rewritten on next save"
);
for r_did in &repair.unrecoverable {
warn!(r_did = %r_did,
"relationship has unrecoverable keys and must be re-established");
}
}
private_cfg
.relationships
.generate_profiles(
tdk,
&our_p_dids,
first_mediator,
&key_backend,
&sc.key_info,
vta_client.as_ref(),
)
.await?;
}
Ok(())
}
.await;
if let Err(e) = hydrate {
if let Some(client) = &vta_client {
client.shutdown().await;
}
return Err(e);
}
let key_ids: Vec<String> = sc.key_info.keys().cloned().collect();
cross_check(&mut integrity, &account, &key_ids);
if !integrity.is_clean() {
warn!(
degraded = integrity.degraded_personas.len(),
stranded = integrity.stranded_memberships.len(),
orphaned = integrity.orphaned_key_ids.len(),
"profile loaded with problems; the user is asked to acknowledge them"
);
}
Ok((
Config {
account,
identities,
integrity,
protected_key,
active_persona: None,
runtime_trust_overrides: None,
key_backend,
public: public_config,
private: private_cfg,
key_info: sc.key_info.clone(),
#[cfg(feature = "openpgp-card")]
token_admin_pin: None,
#[cfg(feature = "openpgp-card")]
token_user_pin: token_user_pin.clone(),
protection_method: sc.protection_method.clone(),
unlock_code: unlock_passphrase
.map(|uc| SecretBox::new(Box::new(uc.0.expose_secret().to_owned()))),
},
vta_client,
))
}
}
fn log_private_config_shape(private_cfg: &ProtectedConfig) {
tracing::debug!(
contacts = private_cfg.contacts.contacts.len(),
relationships = private_cfg.relationships.relationships.len(),
tasks = private_cfg.tasks.tasks.len(),
vrcs_issued = private_cfg.vrcs_issued.keys().len(),
vrcs_received = private_cfg.vrcs_received.keys().len(),
"private config loaded"
);
}
const LOAD_SYNC_BUDGET: std::time::Duration = std::time::Duration::from_secs(20);
async fn sync_account_state(
client: &vta_sdk::client::VtaClient,
private_cfg: &mut ProtectedConfig,
key_info: &mut std::collections::HashMap<String, super::secured_config::KeyInfoConfig>,
) {
use crate::vta_sync::{SyncError, SyncParts, SyncReport, sync_at_load};
let context_id = private_cfg.account.top_context_id.clone();
let mut state = std::mem::take(&mut private_cfg.vta_sync);
let mut account = std::mem::take(&mut private_cfg.account);
let outcome = tokio::time::timeout(LOAD_SYNC_BUDGET, async {
let mut parts = SyncParts {
account: &mut account,
private: &mut *private_cfg,
key_info: &mut *key_info,
};
sync_at_load(client, &mut parts, &mut state, &context_id).await
})
.await;
let report = outcome.unwrap_or_else(|_| SyncReport {
error: Some(SyncError::Unreachable(format!(
"no answer within {}s",
LOAD_SYNC_BUDGET.as_secs()
))),
..SyncReport::default()
});
info!(
pulled = report.pulled,
pushed = report.pushed,
removed = report.removed,
deferred = report.deferred,
conflicts = report.conflicts.len(),
error = ?report.error,
"account state sync at load"
);
private_cfg.account = account;
state.load_report = Some(report);
private_cfg.vta_sync = state;
}
#[cfg(test)]
mod protected_key_fallback_tests {
use super::*;
use crate::config::secured_config::new_protected_key;
fn seed(byte: u8) -> SecretBox<Vec<u8>> {
SecretBox::new(Box::new(vec![byte; 32]))
}
fn blob_under(seed: &SecretBox<Vec<u8>>) -> String {
let mut cfg = ProtectedConfig::default();
cfg.account.top_context_id = "marker".to_string();
cfg.save(seed).expect("encrypt")
}
#[test]
fn a_stored_key_decrypts_and_needs_no_rekey() {
let stored = seed(1);
let blob = blob_under(&stored);
let (cfg, rekey) =
decrypt_protected_with_fallback(Some(&stored), &seed(2), None, &blob).expect("decrypt");
assert_eq!(cfg.account.top_context_id, "marker");
assert!(!rekey);
}
#[test]
fn a_pre_d12_config_falls_back_and_is_flagged() {
let legacy = seed(2);
let blob = blob_under(&legacy);
let (cfg, rekey) =
decrypt_protected_with_fallback(None, &legacy, None, &blob).expect("decrypt");
assert_eq!(cfg.account.top_context_id, "marker");
assert!(rekey, "a pre-D12 config must be re-keyed");
}
#[test]
fn an_interrupted_rekey_still_opens_the_profile() {
let legacy = seed(2);
let blob = blob_under(&legacy);
let stored = crate::config::decode_protected_key(&new_protected_key()).expect("decode");
let (cfg, rekey) = decrypt_protected_with_fallback(Some(&stored), &legacy, None, &blob)
.expect("an interrupted re-key must not lock the user out");
assert_eq!(cfg.account.top_context_id, "marker");
assert!(
rekey,
"the interrupted re-key must be completed on next save"
);
}
#[test]
fn the_oldest_bip32_seed_is_still_tried() {
let oldest = seed(3);
let blob = blob_under(&oldest);
let (cfg, rekey) =
decrypt_protected_with_fallback(Some(&seed(1)), &seed(2), Some(&oldest), &blob)
.expect("decrypt");
assert_eq!(cfg.account.top_context_id, "marker");
assert!(rekey);
}
#[test]
fn an_undecryptable_blob_is_an_error_not_an_empty_config() {
let blob = blob_under(&seed(9));
let err = decrypt_protected_with_fallback(Some(&seed(1)), &seed(2), Some(&seed(3)), &blob);
assert!(err.is_err(), "must not fabricate an empty account");
}
#[test]
fn a_vta_profile_reports_the_legacy_failure() {
let blob = blob_under(&seed(9));
let err =
decrypt_protected_with_fallback(None, &seed(2), None, &blob).expect_err("should fail");
assert!(
matches!(err, OpenVTCError::Decrypt(_)),
"unexpected error: {err}"
);
}
}
#[cfg(test)]
mod integrity_tests {
use super::*;
use crate::config::account::{CommunityRecord, CommunityStatus, PersonaRecord, VtcDid};
use uuid::Uuid;
const ALICE: &str = "did:webvh:QmAlice:example.com:alice";
const BOB: &str = "did:webvh:QmBob:example.com:bob";
const GHOST: &str = "did:webvh:QmGhost:example.com:ghost";
fn persona(did: &str) -> (PersonaId, PersonaRecord) {
let persona_id = PersonaId(Uuid::new_v4());
(
persona_id,
PersonaRecord {
extra: serde_json::Map::new(),
persona_id,
did: did.to_string(),
did_document: None,
key_refs: Vec::new(),
mediator_did: None,
origin_context_id: "top".to_string(),
created_at: chrono::Utc::now(),
label: None,
},
)
}
fn account_with(personas: Vec<(PersonaId, PersonaRecord)>) -> Account {
let mut account = Account::default();
for (id, record) in personas {
account.personas.insert(id, record);
}
account
}
fn active_membership(account: &mut Account, vtc: &str, persona_ref: PersonaId, name: &str) {
let vtc_did = VtcDid::from(vtc.to_string());
let mut record = CommunityRecord::new_pending(
vtc_did.clone(),
Some(name.to_string()),
"top/sub".to_string(),
persona_ref,
Uuid::new_v4(),
chrono::Utc::now(),
);
record.status = CommunityStatus::Active;
account.communities.insert(vtc_did, vec![record]);
}
fn degraded(persona_id: PersonaId, did: &str) -> DegradedPersona {
DegradedPersona {
persona_id,
did: did.to_string(),
label: None,
created_at: chrono::Utc::now(),
reason: DegradedReason::MissingKeyInfo {
key_id: format!("{did}#key-1"),
},
}
}
#[test]
fn a_healthy_account_cross_checks_clean() {
let (alice_id, alice) = persona(ALICE);
let mut account = account_with(vec![(alice_id, alice)]);
active_membership(
&mut account,
"did:webvh:QmV:vtc.example.com:c",
alice_id,
"C",
);
let mut integrity = LoadIntegrity::default();
cross_check(
&mut integrity,
&account,
&[format!("{ALICE}#key-1"), format!("{ALICE}#key-2")],
);
assert!(integrity.is_clean(), "{integrity:?}");
}
#[test]
fn a_degraded_persona_strands_only_its_own_membership() {
let (alice_id, alice) = persona(ALICE);
let (bob_id, bob) = persona(BOB);
let mut account = account_with(vec![(alice_id, alice), (bob_id, bob)]);
active_membership(
&mut account,
"did:webvh:QmV:vtc.example.com:a",
alice_id,
"A",
);
active_membership(&mut account, "did:webvh:QmV:vtc.example.com:b", bob_id, "B");
let mut integrity = LoadIntegrity {
degraded_personas: vec![degraded(alice_id, ALICE)],
..Default::default()
};
cross_check(
&mut integrity,
&account,
&[format!("{ALICE}#key-1"), format!("{BOB}#key-1")],
);
assert_eq!(integrity.stranded_memberships.len(), 1);
assert_eq!(integrity.stranded_memberships[0].persona_id, alice_id);
assert_eq!(
integrity.stranded_memberships[0].label.as_deref(),
Some("A")
);
assert!(integrity.orphaned_key_ids.is_empty());
}
#[test]
fn keys_for_an_unknown_persona_are_reported_as_orphans() {
let (alice_id, alice) = persona(ALICE);
let account = account_with(vec![(alice_id, alice)]);
let mut integrity = LoadIntegrity::default();
cross_check(
&mut integrity,
&account,
&[
format!("{ALICE}#key-1"),
format!("{GHOST}#key-1"),
format!("{GHOST}#key-2"),
],
);
assert_eq!(integrity.orphaned_key_ids.len(), 2);
assert!(integrity.orphaned_key_ids[0].starts_with(GHOST));
assert!(integrity.has_incomplete_write());
}
#[test]
fn relationship_keys_are_not_mistaken_for_orphans() {
let (alice_id, alice) = persona(ALICE);
let account = account_with(vec![(alice_id, alice)]);
let mut integrity = LoadIntegrity::default();
cross_check(
&mut integrity,
&account,
&[
format!("{ALICE}#key-1"),
"did:peer:2.Ez6LS.Vz6Mk#key-1".to_string(),
"did:peer:2.Ez6LS.Vz6Mk#key-2".to_string(),
],
);
assert!(integrity.is_clean(), "{integrity:?}");
}
#[test]
fn a_healthy_persona_keeps_its_membership_when_another_is_degraded() {
let (alice_id, alice) = persona(ALICE);
let (bob_id, bob) = persona(BOB);
let mut account = account_with(vec![(alice_id, alice), (bob_id, bob)]);
active_membership(&mut account, "did:webvh:QmV:vtc.example.com:b", bob_id, "B");
let mut integrity = LoadIntegrity {
degraded_personas: vec![degraded(alice_id, ALICE)],
..Default::default()
};
cross_check(&mut integrity, &account, &[]);
assert!(integrity.stranded_memberships.is_empty());
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
config::protected_config::Contact,
relationships::{Relationship, RelationshipState},
};
use std::{
io::Write,
sync::{Arc, Mutex},
};
#[derive(Clone, Default)]
struct SharedWriter(Arc<Mutex<Vec<u8>>>);
impl Write for SharedWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SharedWriter {
type Writer = SharedWriter;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
#[test]
fn private_config_shape_log_does_not_leak_contents() {
const MARKER: &str = "MARKER_MUST_NOT_LEAK";
let mut private_cfg = ProtectedConfig::default();
let did = Arc::new(format!("did:web:{MARKER}.example.com"));
private_cfg.contacts.contacts.insert(
did.clone(),
Arc::new(Contact {
did: did.clone(),
alias: Some(MARKER.to_string()),
}),
);
private_cfg.relationships.relationships.insert(
did.clone(),
Relationship {
task_id: Arc::new(MARKER.to_string()),
our_did: did.clone(),
remote_did: did.clone(),
remote_p_did: did.clone(),
created: chrono::Utc::now(),
state: RelationshipState::Established,
our_persona: None,
needs_reestablishment: false,
},
);
let writer = SharedWriter::default();
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.with_ansi(false)
.with_writer(writer.clone())
.finish();
tracing::subscriber::with_default(subscriber, || {
log_private_config_shape(&private_cfg);
});
let output = String::from_utf8(writer.0.lock().unwrap().clone()).unwrap();
assert!(
output.contains("private config loaded"),
"shape log line was not captured; got: {output}"
);
assert!(
output.contains("contacts=1") && output.contains("relationships=1"),
"expected collection counts in the log line; got: {output}"
);
assert!(
!output.contains(MARKER),
"decrypted private-config contents leaked into log output: {output}"
);
}
}