1use std::fs;
26use std::path::{Path, PathBuf};
27
28use ed25519_dalek::{Signer, SigningKey};
29use serde::{Deserialize, Serialize};
30use sha2::{Digest, Sha256};
31
32use crate::accountability_ledger::{AccountabilityLedger, LedgerEntry, LedgerTamper};
33use crate::consent_credential::{
34 audit_trail_for_credential, Attestation, ConductRecord, ConsentCredential,
35 EncryptedCommonsPayload, PayloadCommitment,
36};
37use crate::dead_mans_switch::{DeadMansSwitch, Disposition, PartyAttestation};
38use crate::disclosure_trace::{
39 actors_with_access, disclosure_chain, trace_leak, DisclosureEvent, DisclosureFingerprint,
40 TransparencyCc,
41};
42use crate::incapacity_switch::IncapacitySwitch;
43
44pub const STORE_FILE: &str = "wellfair/accountability.json";
46
47pub const KIND_CONSENT_GRANTED: &str = "consent_granted";
49pub const KIND_CONSENT_REVOKED: &str = "consent_revoked";
50pub const KIND_CONDUCT: &str = "conduct";
51pub const KIND_DEAD_MANS_ARMED: &str = "dead_mans_armed";
52pub const KIND_DEAD_MANS_ALIVE: &str = "dead_mans_alive";
53pub const KIND_DEAD_MANS_ATTESTED: &str = "dead_mans_attested";
54pub const KIND_DEAD_MANS_ENACTED: &str = "dead_mans_enacted";
55pub const KIND_INCAPACITY_ARMED: &str = "incapacity_armed";
56pub const KIND_INCAPACITY_ACTIVATED: &str = "incapacity_activated";
57pub const KIND_INCAPACITY_REVERSED: &str = "incapacity_reversed";
58pub const KIND_TRANSPARENCY_CC: &str = "transparency_cc";
59pub const KIND_DISCLOSURE: &str = "disclosure";
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct DeadMansSwitchRecord {
66 pub switch: DeadMansSwitch,
67 #[serde(default)]
68 pub attestations: Vec<PartyAttestation>,
69}
70
71#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
73pub struct AccountabilityState {
74 pub ledger: AccountabilityLedger,
76 pub credentials: Vec<ConsentCredential>,
78 pub conduct: Vec<ConductRecord>,
80 #[serde(default)]
84 pub payloads: Vec<EncryptedCommonsPayload>,
85 #[serde(default)]
87 pub dead_mans_switches: Vec<DeadMansSwitchRecord>,
88 #[serde(default)]
90 pub incapacity_switches: Vec<IncapacitySwitch>,
91 #[serde(default)]
93 pub disclosure_ccs: Vec<TransparencyCc>,
94 #[serde(default)]
96 pub disclosure_events: Vec<DisclosureEvent>,
97}
98
99pub struct AccountabilityStore {
101 path: PathBuf,
102}
103
104impl AccountabilityStore {
105 pub fn open(storage_root: impl AsRef<Path>) -> std::io::Result<Self> {
107 let path = storage_root.as_ref().join(STORE_FILE);
108 if let Some(parent) = path.parent() {
109 fs::create_dir_all(parent)?;
110 }
111 Ok(Self { path })
112 }
113
114 pub fn load(&self) -> std::io::Result<AccountabilityState> {
116 match fs::read(&self.path) {
117 Ok(bytes) => {
118 serde_json::from_slice(&bytes).map_err(|e| std::io::Error::other(e.to_string()))
119 }
120 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
121 Ok(AccountabilityState::default())
122 }
123 Err(e) => Err(e),
124 }
125 }
126
127 pub fn save(&self, state: &AccountabilityState) -> std::io::Result<()> {
129 let bytes =
130 serde_json::to_vec_pretty(state).map_err(|e| std::io::Error::other(e.to_string()))?;
131 let tmp = self.path.with_extension("json.tmp");
132 fs::write(&tmp, &bytes)?;
133 fs::rename(&tmp, &self.path)?;
134 Ok(())
135 }
136
137 pub fn append_ledger(
139 &self,
140 kind: &str,
141 payload_json: &str,
142 signer: &SigningKey,
143 time_unix: u64,
144 ) -> std::io::Result<LedgerEntry> {
145 let mut state = self.load()?;
146 let entry = state
147 .ledger
148 .append(kind, payload_json, signer, time_unix)
149 .clone();
150 self.save(&state)?;
151 Ok(entry)
152 }
153
154 pub fn verify_ledger(&self) -> std::io::Result<Result<(), LedgerTamper>> {
156 Ok(self.load()?.ledger.verify())
157 }
158
159 pub fn ledger_entries(&self, limit: usize) -> std::io::Result<Vec<LedgerEntry>> {
161 let mut entries = self.load()?.ledger.entries().to_vec();
162 entries.reverse();
163 entries.truncate(limit);
164 Ok(entries)
165 }
166
167 pub fn grant_credential(
170 &self,
171 cred: ConsentCredential,
172 signer: &SigningKey,
173 time_unix: u64,
174 ) -> std::io::Result<ConsentCredential> {
175 let mut state = self.load()?;
176 let note = serde_json::json!({
177 "credential_id": cred.id,
178 "subject": cred.subject_did,
179 "agent": cred.agent_did,
180 "scope": cred.scope,
181 "purpose": cred.purpose,
182 "commitment": hex::encode(cred.payload_commitment),
183 })
184 .to_string();
185 state
186 .ledger
187 .append(KIND_CONSENT_GRANTED, ¬e, signer, time_unix);
188 state.credentials.push(cred.clone());
189 self.save(&state)?;
190 Ok(cred)
191 }
192
193 pub fn revoke_credential(
198 &self,
199 credential_id: &str,
200 signer: &SigningKey,
201 time_unix: u64,
202 ) -> std::io::Result<bool> {
203 let mut state = self.load()?;
204 let Some(cred) = state.credentials.iter_mut().find(|c| c.id == credential_id) else {
205 return Ok(false);
206 };
207 let was_active = cred.is_active(time_unix);
208 cred.revoke(time_unix);
209 let note = serde_json::json!({
210 "credential_id": credential_id,
211 "revoked_unix": time_unix,
212 })
213 .to_string();
214 state
215 .ledger
216 .append(KIND_CONSENT_REVOKED, ¬e, signer, time_unix);
217 self.save(&state)?;
218 Ok(was_active)
219 }
220
221 pub fn list_credentials(&self) -> std::io::Result<Vec<ConsentCredential>> {
223 Ok(self.load()?.credentials)
224 }
225
226 pub fn wrapped_key_for(
229 &self,
230 commitment: &PayloadCommitment,
231 agent_did: &str,
232 now_unix: u64,
233 ) -> std::io::Result<Option<Vec<u8>>> {
234 let state = self.load()?;
235 Ok(state
236 .credentials
237 .iter()
238 .find(|c| &c.payload_commitment == commitment && c.agent_did == agent_did)
239 .and_then(|c| c.payload_key(now_unix).map(|k| k.to_vec())))
240 }
241
242 pub fn record_conduct(
247 &self,
248 agent_did: impl Into<String>,
249 credential_id: impl Into<String>,
250 action: impl Into<String>,
251 reason: impl Into<String>,
252 commitment: PayloadCommitment,
253 signer: &SigningKey,
254 time_unix: u64,
255 ) -> std::io::Result<ConductRecord> {
256 let agent_did = agent_did.into();
257 let credential_id = credential_id.into();
258 let action = action.into();
259 let reason = reason.into();
260
261 let bound = content_signing_bytes(
263 &agent_did,
264 &credential_id,
265 &action,
266 &reason,
267 &commitment,
268 time_unix,
269 );
270 let sig = signer.sign(&bound);
271 let id = conduct_id(&agent_did, &credential_id, &action, time_unix);
272
273 let record = ConductRecord {
274 id,
275 agent_did,
276 credential_id,
277 action,
278 reason,
279 time_unix,
280 payload_commitment: commitment,
281 attestation: Attestation::Signature {
282 alg: "ed25519".into(),
283 sig_hex: hex::encode(sig.to_bytes()),
284 },
285 };
286
287 let mut state = self.load()?;
288 let payload =
289 serde_json::to_string(&record).map_err(|e| std::io::Error::other(e.to_string()))?;
290 state
291 .ledger
292 .append(KIND_CONDUCT, &payload, signer, time_unix);
293 state.conduct.push(record.clone());
294 self.save(&state)?;
295 Ok(record)
296 }
297
298 pub fn audit_trail(&self, credential_id: &str) -> std::io::Result<Vec<ConductRecord>> {
302 let state = self.load()?;
303 Ok(audit_trail_for_credential(&state.conduct, credential_id)
304 .into_iter()
305 .cloned()
306 .collect())
307 }
308
309 #[cfg(not(target_arch = "wasm32"))]
318 #[allow(clippy::too_many_arguments)]
319 pub fn seal_and_grant_credential(
320 &self,
321 credential_id: impl Into<String>,
322 subject_did: impl Into<String>,
323 agent_did: impl Into<String>,
324 scope: impl Into<String>,
325 purpose: impl Into<String>,
326 plaintext: &[u8],
327 recipient_public: &[u8; 32],
328 storers: Vec<String>,
329 expiry_unix: Option<u64>,
330 signer: &SigningKey,
331 time_unix: u64,
332 ) -> std::io::Result<ConsentCredential> {
333 use crate::envelope_encryption::{seal_payload, wrap_dek_to};
334 let (payload, dek) = seal_payload(plaintext, storers).map_err(std::io::Error::other)?;
335 let wrapped = wrap_dek_to(recipient_public, &dek).map_err(std::io::Error::other)?;
336 let commitment = payload.commitment;
337 let cred = ConsentCredential::grant(
338 credential_id,
339 subject_did,
340 agent_did,
341 scope,
342 purpose,
343 commitment,
344 wrapped,
345 time_unix,
346 expiry_unix,
347 );
348
349 let mut state = self.load()?;
350 state.payloads.push(payload);
351 let note = serde_json::json!({
352 "credential_id": cred.id,
353 "subject": cred.subject_did,
354 "agent": cred.agent_did,
355 "scope": cred.scope,
356 "purpose": cred.purpose,
357 "commitment": hex::encode(commitment),
358 "sealed": true,
359 })
360 .to_string();
361 state
362 .ledger
363 .append(KIND_CONSENT_GRANTED, ¬e, signer, time_unix);
364 state.credentials.push(cred.clone());
365 self.save(&state)?;
366 Ok(cred)
367 }
368
369 #[cfg(not(target_arch = "wasm32"))]
374 pub fn open_payload_via_credential(
375 &self,
376 credential_id: &str,
377 recipient_secret: &[u8; 32],
378 now_unix: u64,
379 ) -> std::io::Result<Vec<u8>> {
380 use crate::envelope_encryption::open_payload_with_wrapped;
381 let state = self.load()?;
382 let cred = state
383 .credentials
384 .iter()
385 .find(|c| c.id == credential_id)
386 .ok_or_else(|| {
387 std::io::Error::other(format!("credential '{credential_id}' not found"))
388 })?;
389 let wrapped = cred.payload_key(now_unix).ok_or_else(|| {
390 std::io::Error::other(
391 "credential revoked or expired — the wrapped key is destroyed, payload unavailable",
392 )
393 })?;
394 let payload = state
395 .payloads
396 .iter()
397 .find(|p| p.commitment == cred.payload_commitment)
398 .ok_or_else(|| {
399 std::io::Error::other("sealed payload for this credential not found in the commons")
400 })?;
401 open_payload_with_wrapped(payload, recipient_secret, wrapped).map_err(std::io::Error::other)
402 }
403
404 pub fn arm_dead_mans_switch(
410 &self,
411 switch: DeadMansSwitch,
412 signer: &SigningKey,
413 time_unix: u64,
414 ) -> std::io::Result<()> {
415 let mut state = self.load()?;
416 let note = serde_json::json!({
417 "commitment": hex::encode(switch.payload_commitment),
418 "threshold": switch.trigger.attestation_threshold,
419 "parties": switch.trigger.parties,
420 })
421 .to_string();
422 state
423 .ledger
424 .append(KIND_DEAD_MANS_ARMED, ¬e, signer, time_unix);
425 if let Some(rec) = state
427 .dead_mans_switches
428 .iter_mut()
429 .find(|r| r.switch.payload_commitment == switch.payload_commitment)
430 {
431 rec.switch = switch;
432 rec.attestations.clear();
433 } else {
434 state.dead_mans_switches.push(DeadMansSwitchRecord {
435 switch,
436 attestations: Vec::new(),
437 });
438 }
439 self.save(&state)?;
440 Ok(())
441 }
442
443 pub fn dead_mans_alive(
446 &self,
447 commitment: &PayloadCommitment,
448 signer: &SigningKey,
449 time_unix: u64,
450 ) -> std::io::Result<bool> {
451 let mut state = self.load()?;
452 let Some(rec) = state
453 .dead_mans_switches
454 .iter_mut()
455 .find(|r| &r.switch.payload_commitment == commitment)
456 else {
457 return Ok(false);
458 };
459 rec.switch.principal_alive(time_unix);
460 let note = serde_json::json!({ "commitment": hex::encode(commitment) }).to_string();
461 state
462 .ledger
463 .append(KIND_DEAD_MANS_ALIVE, ¬e, signer, time_unix);
464 self.save(&state)?;
465 Ok(true)
466 }
467
468 pub fn attest_dead_mans(
471 &self,
472 commitment: &PayloadCommitment,
473 attestation: PartyAttestation,
474 signer: &SigningKey,
475 time_unix: u64,
476 ) -> std::io::Result<bool> {
477 let mut state = self.load()?;
478 let Some(rec) = state
479 .dead_mans_switches
480 .iter_mut()
481 .find(|r| &r.switch.payload_commitment == commitment)
482 else {
483 return Ok(false);
484 };
485 let note = serde_json::json!({
486 "commitment": hex::encode(commitment),
487 "party": attestation.party_did,
488 })
489 .to_string();
490 rec.attestations
492 .retain(|a| a.party_did != attestation.party_did);
493 rec.attestations.push(attestation);
494 state
495 .ledger
496 .append(KIND_DEAD_MANS_ATTESTED, ¬e, signer, time_unix);
497 self.save(&state)?;
498 Ok(true)
499 }
500
501 pub fn enact_dead_mans(
504 &self,
505 commitment: &PayloadCommitment,
506 signer: &SigningKey,
507 time_unix: u64,
508 ) -> std::io::Result<Option<Disposition>> {
509 let mut state = self.load()?;
510 let Some(rec) = state
511 .dead_mans_switches
512 .iter_mut()
513 .find(|r| &r.switch.payload_commitment == commitment)
514 else {
515 return Ok(None);
516 };
517 let attestations = rec.attestations.clone();
518 let disposition = rec.switch.enact(&attestations, time_unix).cloned();
519 if disposition.is_some() {
520 let note = serde_json::json!({ "commitment": hex::encode(commitment) }).to_string();
521 state
522 .ledger
523 .append(KIND_DEAD_MANS_ENACTED, ¬e, signer, time_unix);
524 self.save(&state)?;
525 }
526 Ok(disposition)
527 }
528
529 pub fn list_dead_mans_switches(&self) -> std::io::Result<Vec<DeadMansSwitchRecord>> {
531 Ok(self.load()?.dead_mans_switches)
532 }
533
534 #[cfg(not(target_arch = "wasm32"))]
541 pub fn enact_dead_mans_release(
542 &self,
543 commitment: &PayloadCommitment,
544 dek: &[u8; 32],
545 party_keys: &[(String, [u8; 32])],
546 subject_did: &str,
547 signer: &SigningKey,
548 time_unix: u64,
549 ) -> std::io::Result<Option<Disposition>> {
550 let mut state = self.load()?;
551 let disp = Self::enact_and_release_in_state(
552 &mut state,
553 commitment,
554 dek,
555 party_keys,
556 subject_did,
557 signer,
558 time_unix,
559 )
560 .map_err(std::io::Error::other)?;
561 if disp.is_some() {
562 self.save(&state)?;
563 }
564 Ok(disp)
565 }
566
567 #[cfg(not(target_arch = "wasm32"))]
572 pub fn reconstruct_and_release(
573 &self,
574 commitment: &PayloadCommitment,
575 recovery_shares: &[crate::shamir_recovery::Share],
576 party_keys: &[(String, [u8; 32])],
577 subject_did: &str,
578 signer: &SigningKey,
579 time_unix: u64,
580 ) -> std::io::Result<Option<Disposition>> {
581 let dek_vec =
582 crate::shamir_recovery::reconstruct(recovery_shares).map_err(std::io::Error::other)?;
583 let dek: [u8; 32] = dek_vec
584 .as_slice()
585 .try_into()
586 .map_err(|_| std::io::Error::other("reconstructed DEK is not 32 bytes"))?;
587 let mut state = self.load()?;
588 let disp = Self::enact_and_release_in_state(
589 &mut state,
590 commitment,
591 &dek,
592 party_keys,
593 subject_did,
594 signer,
595 time_unix,
596 )
597 .map_err(std::io::Error::other)?;
598 if disp.is_some() {
599 self.save(&state)?;
600 }
601 Ok(disp)
602 }
603
604 #[cfg(not(target_arch = "wasm32"))]
608 fn enact_and_release_in_state(
609 state: &mut AccountabilityState,
610 commitment: &PayloadCommitment,
611 dek: &[u8; 32],
612 party_keys: &[(String, [u8; 32])],
613 subject_did: &str,
614 signer: &SigningKey,
615 time_unix: u64,
616 ) -> Result<Option<Disposition>, String> {
617 use crate::envelope_encryption::wrap_dek_to;
618 let Some(rec) = state
619 .dead_mans_switches
620 .iter_mut()
621 .find(|r| &r.switch.payload_commitment == commitment)
622 else {
623 return Ok(None);
624 };
625 let attestations = rec.attestations.clone();
626 let Some(disposition) = rec.switch.enact(&attestations, time_unix).cloned() else {
627 return Ok(None);
628 };
629 let enote = serde_json::json!({ "commitment": hex::encode(commitment), "released": true })
630 .to_string();
631 state
632 .ledger
633 .append(KIND_DEAD_MANS_ENACTED, &enote, signer, time_unix);
634
635 if let Disposition::ReleaseTo { parties } = &disposition {
636 for did in parties.clone() {
637 let Some((_, pk)) = party_keys.iter().find(|(d, _)| d == &did) else {
638 continue; };
640 let wrapped = wrap_dek_to(pk, dek)?;
641 let digest = Sha256::digest(format!("release:{did}:{time_unix}").as_bytes());
642 let id = format!("cc-{}", hex::encode(&digest[..6]));
643 let cred = ConsentCredential::grant(
644 id.clone(),
645 subject_did,
646 &did,
647 "dead-man-release",
648 "post-death disposition",
649 *commitment,
650 wrapped,
651 time_unix,
652 None,
653 );
654 let gnote = serde_json::json!({
655 "credential_id": id,
656 "agent": did,
657 "commitment": hex::encode(commitment),
658 "via": "dead_mans_release",
659 })
660 .to_string();
661 state
662 .ledger
663 .append(KIND_CONSENT_GRANTED, &gnote, signer, time_unix);
664 state.credentials.push(cred);
665 }
666 }
667 Ok(Some(disposition))
668 }
669
670 pub fn arm_incapacity_switch(
674 &self,
675 switch: IncapacitySwitch,
676 signer: &SigningKey,
677 time_unix: u64,
678 ) -> std::io::Result<()> {
679 let mut state = self.load()?;
680 let note = serde_json::json!({
681 "principal": switch.principal_did,
682 "advocate": switch.advocate_did,
683 "threshold": switch.trigger.attestation_threshold,
684 })
685 .to_string();
686 state
687 .ledger
688 .append(KIND_INCAPACITY_ARMED, ¬e, signer, time_unix);
689 if let Some(existing) = state
690 .incapacity_switches
691 .iter_mut()
692 .find(|s| s.principal_did == switch.principal_did)
693 {
694 *existing = switch;
695 } else {
696 state.incapacity_switches.push(switch);
697 }
698 self.save(&state)?;
699 Ok(())
700 }
701
702 pub fn activate_incapacity(
705 &self,
706 principal_did: &str,
707 attesting_parties: &[String],
708 official_instrument: Option<&str>,
709 signer: &SigningKey,
710 time_unix: u64,
711 ) -> std::io::Result<bool> {
712 let mut state = self.load()?;
713 let Some(switch) = state
714 .incapacity_switches
715 .iter_mut()
716 .find(|s| s.principal_did == principal_did)
717 else {
718 return Ok(false);
719 };
720 let activated = switch.activate(attesting_parties, official_instrument, time_unix);
721 if activated {
722 let note = serde_json::json!({
723 "principal": principal_did,
724 "official_instrument": official_instrument.is_some(),
725 })
726 .to_string();
727 state
728 .ledger
729 .append(KIND_INCAPACITY_ACTIVATED, ¬e, signer, time_unix);
730 self.save(&state)?;
731 }
732 Ok(activated)
733 }
734
735 pub fn regain_capacity(
738 &self,
739 principal_did: &str,
740 signer: &SigningKey,
741 time_unix: u64,
742 ) -> std::io::Result<bool> {
743 let mut state = self.load()?;
744 let Some(switch) = state
745 .incapacity_switches
746 .iter_mut()
747 .find(|s| s.principal_did == principal_did)
748 else {
749 return Ok(false);
750 };
751 switch.regain_capacity(time_unix);
752 let note = serde_json::json!({ "principal": principal_did }).to_string();
753 state
754 .ledger
755 .append(KIND_INCAPACITY_REVERSED, ¬e, signer, time_unix);
756 self.save(&state)?;
757 Ok(true)
758 }
759
760 pub fn list_incapacity_switches(&self) -> std::io::Result<Vec<IncapacitySwitch>> {
762 Ok(self.load()?.incapacity_switches)
763 }
764
765 pub fn record_transparency_cc(
770 &self,
771 cc: TransparencyCc,
772 signer: &SigningKey,
773 time_unix: u64,
774 ) -> std::io::Result<()> {
775 let mut state = self.load()?;
776 let note = serde_json::json!({
777 "authority": cc.informed_authority_did,
778 "credential_id": cc.credential_id,
779 "purpose": cc.purpose,
780 })
781 .to_string();
782 state
783 .ledger
784 .append(KIND_TRANSPARENCY_CC, ¬e, signer, time_unix);
785 state.disclosure_ccs.push(cc);
786 self.save(&state)?;
787 Ok(())
788 }
789
790 pub fn record_disclosure_event(
794 &self,
795 event: DisclosureEvent,
796 signer: &SigningKey,
797 time_unix: u64,
798 ) -> std::io::Result<()> {
799 let mut state = self.load()?;
800 let note = serde_json::json!({
801 "commitment": hex::encode(event.payload_commitment),
802 "recipient": event.recipient_did,
803 "actor": event.accountable_actor(),
804 "id": event.id,
805 })
806 .to_string();
807 state
808 .ledger
809 .append(KIND_DISCLOSURE, ¬e, signer, time_unix);
810 state.disclosure_events.push(event);
811 self.save(&state)?;
812 Ok(())
813 }
814
815 pub fn disclosure_chain(
817 &self,
818 commitment: &PayloadCommitment,
819 ) -> std::io::Result<Vec<DisclosureEvent>> {
820 let state = self.load()?;
821 Ok(disclosure_chain(&state.disclosure_events, commitment)
822 .into_iter()
823 .cloned()
824 .collect())
825 }
826
827 pub fn actors_with_access(
829 &self,
830 commitment: &PayloadCommitment,
831 ) -> std::io::Result<Vec<String>> {
832 let state = self.load()?;
833 Ok(actors_with_access(&state.disclosure_events, commitment)
834 .into_iter()
835 .map(|s| s.to_string())
836 .collect())
837 }
838
839 pub fn trace_leak(
842 &self,
843 fingerprint: &DisclosureFingerprint,
844 ) -> std::io::Result<Option<DisclosureEvent>> {
845 let state = self.load()?;
846 Ok(trace_leak(&state.disclosure_events, fingerprint).cloned())
847 }
848
849 pub fn list_transparency_ccs(&self) -> std::io::Result<Vec<TransparencyCc>> {
851 Ok(self.load()?.disclosure_ccs)
852 }
853}
854
855fn content_signing_bytes(
857 agent_did: &str,
858 credential_id: &str,
859 action: &str,
860 reason: &str,
861 commitment: &PayloadCommitment,
862 time_unix: u64,
863) -> [u8; 32] {
864 let mut h = Sha256::new();
865 for part in [agent_did, credential_id, action, reason] {
866 h.update(part.as_bytes());
867 h.update(b"\x1f");
868 }
869 h.update(commitment);
870 h.update(b"\x1f");
871 h.update(time_unix.to_le_bytes());
872 h.finalize().into()
873}
874
875fn conduct_id(agent_did: &str, credential_id: &str, action: &str, time_unix: u64) -> String {
876 let digest =
877 Sha256::digest(format!("{agent_did}:{credential_id}:{action}:{time_unix}").as_bytes());
878 format!("cd-{}", hex::encode(&digest[..6]))
879}
880
881pub fn parse_commitment_hex(s: &str) -> Result<PayloadCommitment, String> {
883 let bytes = hex::decode(s.trim()).map_err(|e| format!("commitment not hex: {e}"))?;
884 bytes
885 .as_slice()
886 .try_into()
887 .map_err(|_| format!("commitment must be 32 bytes, got {}", bytes.len()))
888}
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893
894 fn signer(seed: u8) -> SigningKey {
895 SigningKey::from_bytes(&[seed; 32])
896 }
897
898 fn cred(id: &str, commitment: PayloadCommitment) -> ConsentCredential {
899 ConsentCredential::grant(
900 id,
901 "did:wf:person",
902 "did:wf:social-worker",
903 "housing-support",
904 "assess and arrange support",
905 commitment,
906 b"wrapped-key".to_vec(),
907 1_000,
908 None,
909 )
910 }
911
912 #[test]
913 fn grant_then_list_persists_and_logs_to_ledger() {
914 let dir = tempfile::tempdir().unwrap();
915 let store = AccountabilityStore::open(dir.path()).unwrap();
916 let sk = signer(1);
917 store
918 .grant_credential(cred("cc-1", [7u8; 32]), &sk, 1_000)
919 .unwrap();
920
921 let store2 = AccountabilityStore::open(dir.path()).unwrap();
923 let creds = store2.list_credentials().unwrap();
924 assert_eq!(creds.len(), 1);
925 assert_eq!(creds[0].id, "cc-1");
926 assert_eq!(store2.verify_ledger().unwrap(), Ok(()));
928 assert_eq!(
929 store2
930 .load()
931 .unwrap()
932 .ledger
933 .of_kind(KIND_CONSENT_GRANTED)
934 .len(),
935 1
936 );
937 }
938
939 #[test]
940 fn conduct_survives_revocation_and_stays_auditable() {
941 let dir = tempfile::tempdir().unwrap();
942 let store = AccountabilityStore::open(dir.path()).unwrap();
943 let sk = signer(2);
944 let commitment = [9u8; 32];
945 store
946 .grant_credential(cred("cc-9", commitment), &sk, 1_000)
947 .unwrap();
948
949 store
950 .record_conduct(
951 "did:wf:social-worker",
952 "cc-9",
953 "accessed housing record",
954 "under consent",
955 commitment,
956 &sk,
957 1_100,
958 )
959 .unwrap();
960 store
961 .record_conduct(
962 "did:wf:social-worker",
963 "cc-9",
964 "requested placement",
965 "under consent",
966 commitment,
967 &sk,
968 1_150,
969 )
970 .unwrap();
971
972 assert!(store.revoke_credential("cc-9", &sk, 1_200).unwrap());
974 let creds = store.list_credentials().unwrap();
975 assert!(
976 !creds[0].payload_accessible(1_300),
977 "revoked → payload unavailable"
978 );
979
980 let trail = store.audit_trail("cc-9").unwrap();
981 assert_eq!(trail.len(), 2, "both acts survive revocation");
982 assert!(trail.iter().all(|r| r.concerns_commitment(&commitment)));
983 assert!(trail
984 .iter()
985 .all(|r| matches!(r.attestation, Attestation::Signature { .. })));
986
987 assert_eq!(store.verify_ledger().unwrap(), Ok(()));
989 assert_eq!(store.load().unwrap().ledger.len(), 4);
990 }
991
992 #[test]
993 fn a_dropped_ledger_entry_is_detected_after_reload() {
994 let dir = tempfile::tempdir().unwrap();
995 let store = AccountabilityStore::open(dir.path()).unwrap();
996 let sk = signer(3);
997 store.append_ledger("conduct", "a", &sk, 1_000).unwrap();
998 store.append_ledger("conduct", "b", &sk, 1_100).unwrap();
999 store.append_ledger("conduct", "c", &sk, 1_200).unwrap();
1000
1001 let mut state = store.load().unwrap();
1003 state.ledger = {
1004 let mut kept: Vec<_> = state.ledger.entries().to_vec();
1006 kept.remove(1);
1007 serde_json::from_value(serde_json::json!({ "entries": kept })).unwrap()
1008 };
1009 store.save(&state).unwrap();
1010
1011 assert!(
1012 matches!(store.verify_ledger().unwrap(), Err(_)),
1013 "deletion is detectable on reload"
1014 );
1015 }
1016
1017 #[cfg(not(target_arch = "wasm32"))]
1018 #[test]
1019 fn seal_grant_open_then_revoke_denies_but_bytes_survive() {
1020 use crate::envelope_encryption::EnvelopeKeypair;
1021 let dir = tempfile::tempdir().unwrap();
1022 let store = AccountabilityStore::open(dir.path()).unwrap();
1023 let sk = signer(5);
1024 let agent = EnvelopeKeypair::generate().unwrap();
1025
1026 let cred = store
1028 .seal_and_grant_credential(
1029 "cc-seal",
1030 "did:wf:person",
1031 "did:wf:social-worker",
1032 "housing-support",
1033 "assess and arrange support",
1034 b"the sensitive housing record",
1035 &agent.public,
1036 vec!["did:wf:person".into(), "did:wf:archive".into()],
1037 None,
1038 &sk,
1039 1_000,
1040 )
1041 .unwrap();
1042
1043 let opened = store
1045 .open_payload_via_credential(&cred.id, &agent.secret, 1_100)
1046 .unwrap();
1047 assert_eq!(opened, b"the sensitive housing record");
1048
1049 let st = store.load().unwrap();
1051 assert_eq!(st.payloads.len(), 1);
1052 assert_ne!(
1053 st.payloads[0].ciphertext.as_slice(),
1054 b"the sensitive housing record"
1055 );
1056
1057 assert!(store.revoke_credential(&cred.id, &sk, 1_200).unwrap());
1059 assert!(store
1060 .open_payload_via_credential(&cred.id, &agent.secret, 1_300)
1061 .is_err());
1062 let st = store.load().unwrap();
1064 assert_eq!(st.payloads.len(), 1);
1065 assert!(st.payloads[0].is_durable());
1066 assert_eq!(store.verify_ledger().unwrap(), Ok(()));
1068 }
1069
1070 #[test]
1071 fn dead_mans_switch_gamified_trigger_and_reversibility() {
1072 use crate::dead_mans_switch::{
1073 AttestationKind, DeadMansSwitch, Disposition, Heartbeat, PartyAttestation, TriggerRule,
1074 };
1075 let dir = tempfile::tempdir().unwrap();
1076 let store = AccountabilityStore::open(dir.path()).unwrap();
1077 let sk = signer(6);
1078 let c = [3u8; 32];
1079 let att = |who: &str, t: u64| PartyAttestation {
1080 party_did: who.into(),
1081 kind: AttestationKind::BelievedDead,
1082 time_unix: t,
1083 };
1084 let sw = DeadMansSwitch {
1085 payload_commitment: c,
1086 heartbeat: Heartbeat::new(1_000, 100),
1087 trigger: TriggerRule {
1088 require_heartbeat_lapsed: true,
1089 attestation_threshold: 2,
1090 parties: vec!["a".into(), "b".into()],
1091 },
1092 disposition: Disposition::ReleaseTo {
1093 parties: vec!["trustee".into()],
1094 },
1095 fired_unix: None,
1096 };
1097 store.arm_dead_mans_switch(sw, &sk, 1_000).unwrap();
1098
1099 store
1101 .attest_dead_mans(&c, att("a", 1_200), &sk, 1_200)
1102 .unwrap();
1103 assert!(store.enact_dead_mans(&c, &sk, 1_200).unwrap().is_none());
1104 store
1106 .attest_dead_mans(&c, att("b", 1_200), &sk, 1_200)
1107 .unwrap();
1108 assert!(
1109 store.dead_mans_alive(&c, &sk, 1_200).unwrap(),
1110 "principal alive = reversibility"
1111 );
1112 assert!(
1113 store.enact_dead_mans(&c, &sk, 1_250).unwrap().is_none(),
1114 "alive at 1200, grace 100 → not lapsed until 1300"
1115 );
1116 let disp = store.enact_dead_mans(&c, &sk, 1_400).unwrap();
1118 assert!(matches!(disp, Some(Disposition::ReleaseTo { .. })));
1119 assert_eq!(store.verify_ledger().unwrap(), Ok(()));
1121 }
1122
1123 #[cfg(not(target_arch = "wasm32"))]
1124 #[test]
1125 fn dead_mans_release_hands_the_key_to_the_disposition_party() {
1126 use crate::dead_mans_switch::{
1127 AttestationKind, DeadMansSwitch, Disposition, Heartbeat, PartyAttestation, TriggerRule,
1128 };
1129 use crate::envelope_encryption::{
1130 open_payload_with_wrapped, seal_payload, EnvelopeKeypair,
1131 };
1132 let dir = tempfile::tempdir().unwrap();
1133 let store = AccountabilityStore::open(dir.path()).unwrap();
1134 let sk = signer(9);
1135 let trustee = EnvelopeKeypair::generate().unwrap();
1136
1137 let (payload, dek) =
1139 seal_payload(b"the estate letter", vec!["did:wf:person".into()]).unwrap();
1140 let c = payload.commitment;
1141 {
1142 let mut st = store.load().unwrap();
1143 st.payloads.push(payload);
1144 store.save(&st).unwrap();
1145 }
1146 assert!(store
1148 .wrapped_key_for(&c, "did:wf:trustee", 1_100)
1149 .unwrap()
1150 .is_none());
1151
1152 store
1154 .arm_dead_mans_switch(
1155 DeadMansSwitch {
1156 payload_commitment: c,
1157 heartbeat: Heartbeat::new(1_000, 100),
1158 trigger: TriggerRule {
1159 require_heartbeat_lapsed: true,
1160 attestation_threshold: 1,
1161 parties: vec!["did:wf:friend".into()],
1162 },
1163 disposition: Disposition::ReleaseTo {
1164 parties: vec!["did:wf:trustee".into()],
1165 },
1166 fired_unix: None,
1167 },
1168 &sk,
1169 1_000,
1170 )
1171 .unwrap();
1172 store
1173 .attest_dead_mans(
1174 &c,
1175 PartyAttestation {
1176 party_did: "did:wf:friend".into(),
1177 kind: AttestationKind::BelievedDead,
1178 time_unix: 1_200,
1179 },
1180 &sk,
1181 1_200,
1182 )
1183 .unwrap();
1184
1185 let party_keys = vec![("did:wf:trustee".to_string(), trustee.public)];
1187 let disp = store
1188 .enact_dead_mans_release(&c, &dek, &party_keys, "did:wf:person", &sk, 1_200)
1189 .unwrap();
1190 assert!(matches!(disp, Some(Disposition::ReleaseTo { .. })));
1191
1192 let st = store.load().unwrap();
1195 let cred = st
1196 .credentials
1197 .iter()
1198 .find(|c2| c2.agent_did == "did:wf:trustee")
1199 .expect("trustee credential granted on enact");
1200 let wrapped = cred.payload_key(1_300).expect("live wrapped key");
1201 let payload = st.payloads.iter().find(|p| p.commitment == c).unwrap();
1202 let opened = open_payload_with_wrapped(payload, &trustee.secret, wrapped).unwrap();
1203 assert_eq!(opened, b"the estate letter");
1204 assert_eq!(store.verify_ledger().unwrap(), Ok(()));
1205 }
1206
1207 #[test]
1208 fn incapacity_switch_activates_with_quorum_and_reverses() {
1209 use crate::incapacity_switch::{IncapacityKind, IncapacitySwitch, IncapacityTrigger};
1210 let dir = tempfile::tempdir().unwrap();
1211 let store = AccountabilityStore::open(dir.path()).unwrap();
1212 let sk = signer(7);
1213 let sw = IncapacitySwitch {
1214 principal_did: "did:wf:person".into(),
1215 kind: IncapacityKind::InvoluntaryPsychiatric,
1216 trigger: IncapacityTrigger {
1217 parties: vec!["adv".into(), "friend".into()],
1218 attestation_threshold: 2,
1219 require_official_instrument: false,
1220 },
1221 advocate_did: "did:wf:advocate".into(),
1222 active_since_unix: None,
1223 };
1224 store.arm_incapacity_switch(sw, &sk, 1_000).unwrap();
1225 assert!(!store
1227 .activate_incapacity("did:wf:person", &["adv".into()], None, &sk, 1_100)
1228 .unwrap());
1229 assert!(store
1231 .activate_incapacity(
1232 "did:wf:person",
1233 &["adv".into(), "friend".into()],
1234 None,
1235 &sk,
1236 1_100
1237 )
1238 .unwrap());
1239 assert!(store.list_incapacity_switches().unwrap()[0].advocate_active());
1240 assert!(store.regain_capacity("did:wf:person", &sk, 1_500).unwrap());
1242 assert!(!store.list_incapacity_switches().unwrap()[0].advocate_active());
1243 assert_eq!(store.verify_ledger().unwrap(), Ok(()));
1244 }
1245
1246 #[cfg(not(target_arch = "wasm32"))]
1247 #[test]
1248 fn social_recovery_reconstructs_the_key_without_the_owner() {
1249 use crate::dead_mans_switch::{
1250 AttestationKind, DeadMansSwitch, Disposition, Heartbeat, PartyAttestation, TriggerRule,
1251 };
1252 use crate::envelope_encryption::{
1253 open_payload_with_wrapped, seal_payload, EnvelopeKeypair,
1254 };
1255 use crate::shamir_recovery::split;
1256 let dir = tempfile::tempdir().unwrap();
1257 let store = AccountabilityStore::open(dir.path()).unwrap();
1258 let sk = signer(10);
1259 let trustee = EnvelopeKeypair::generate().unwrap();
1260
1261 let (payload, dek) =
1263 seal_payload(b"the will and testament", vec!["did:wf:person".into()]).unwrap();
1264 let c = payload.commitment;
1265 {
1266 let mut st = store.load().unwrap();
1267 st.payloads.push(payload);
1268 store.save(&st).unwrap();
1269 }
1270 let shares = split(&dek, 2, 3).unwrap();
1271
1272 store
1273 .arm_dead_mans_switch(
1274 DeadMansSwitch {
1275 payload_commitment: c,
1276 heartbeat: Heartbeat::new(1_000, 100),
1277 trigger: TriggerRule {
1278 require_heartbeat_lapsed: true,
1279 attestation_threshold: 1,
1280 parties: vec!["did:wf:friend".into()],
1281 },
1282 disposition: Disposition::ReleaseTo {
1283 parties: vec!["did:wf:trustee".into()],
1284 },
1285 fired_unix: None,
1286 },
1287 &sk,
1288 1_000,
1289 )
1290 .unwrap();
1291 store
1292 .attest_dead_mans(
1293 &c,
1294 PartyAttestation {
1295 party_did: "did:wf:friend".into(),
1296 kind: AttestationKind::BelievedDead,
1297 time_unix: 1_200,
1298 },
1299 &sk,
1300 1_200,
1301 )
1302 .unwrap();
1303
1304 let quorum = vec![shares[0].clone(), shares[2].clone()];
1307 let party_keys = vec![("did:wf:trustee".to_string(), trustee.public)];
1308 let disp = store
1309 .reconstruct_and_release(&c, &quorum, &party_keys, "did:wf:person", &sk, 1_200)
1310 .unwrap();
1311 assert!(matches!(disp, Some(Disposition::ReleaseTo { .. })));
1312
1313 let st = store.load().unwrap();
1315 let cred = st
1316 .credentials
1317 .iter()
1318 .find(|c2| c2.agent_did == "did:wf:trustee")
1319 .unwrap();
1320 let wrapped = cred.payload_key(1_300).unwrap();
1321 let p = st.payloads.iter().find(|p| p.commitment == c).unwrap();
1322 assert_eq!(
1323 open_payload_with_wrapped(p, &trustee.secret, wrapped).unwrap(),
1324 b"the will and testament"
1325 );
1326 assert_eq!(store.verify_ledger().unwrap(), Ok(()));
1327 }
1328
1329 #[test]
1330 fn disclosure_trace_records_and_attributes_a_staff_leak() {
1331 use crate::disclosure_trace::{DisclosureEvent, DisclosureKind, TransparencyCc};
1332 let dir = tempfile::tempdir().unwrap();
1333 let store = AccountabilityStore::open(dir.path()).unwrap();
1334 let sk = signer(8);
1335 let c = [9u8; 32];
1336 let fp_staff = [2u8; 16];
1337
1338 store
1340 .record_transparency_cc(
1341 TransparencyCc {
1342 credential_id: "cc-t".into(),
1343 informed_authority_did: "did:wf:mp".into(),
1344 purpose: "protection from serious crime".into(),
1345 informed_unix: 1_000,
1346 },
1347 &sk,
1348 1_000,
1349 )
1350 .unwrap();
1351 store
1352 .record_disclosure_event(
1353 DisclosureEvent {
1354 id: "d1".into(),
1355 payload_commitment: c,
1356 credential_id: "cc-t".into(),
1357 recipient_did: "did:wf:mp".into(),
1358 acting_delegate_did: None,
1359 time_unix: 1_100,
1360 fingerprint: [1u8; 16],
1361 kind: DisclosureKind::DirectAccess,
1362 },
1363 &sk,
1364 1_100,
1365 )
1366 .unwrap();
1367 store
1368 .record_disclosure_event(
1369 DisclosureEvent {
1370 id: "d2".into(),
1371 payload_commitment: c,
1372 credential_id: "cc-t".into(),
1373 recipient_did: "did:wf:mp".into(),
1374 acting_delegate_did: Some("did:wf:staffer".into()),
1375 time_unix: 1_200,
1376 fingerprint: fp_staff,
1377 kind: DisclosureKind::OnwardShare {
1378 to_did: "did:wf:perpetrator".into(),
1379 },
1380 },
1381 &sk,
1382 1_200,
1383 )
1384 .unwrap();
1385
1386 assert_eq!(store.disclosure_chain(&c).unwrap().len(), 2);
1387 let actors = store.actors_with_access(&c).unwrap();
1388 assert!(actors.contains(&"did:wf:mp".to_string()));
1389 assert!(
1390 actors.contains(&"did:wf:staffer".to_string()),
1391 "the acting staffer is in the access set"
1392 );
1393 let ev = store.trace_leak(&fp_staff).unwrap().unwrap();
1395 assert_eq!(ev.accountable_actor(), "did:wf:staffer");
1396 assert_eq!(store.verify_ledger().unwrap(), Ok(()));
1397 }
1398
1399 #[test]
1400 fn parse_commitment_hex_roundtrips() {
1401 let c = [0x2au8; 32];
1402 let hexed = hex::encode(c);
1403 assert_eq!(parse_commitment_hex(&hexed).unwrap(), c);
1404 assert!(parse_commitment_hex("zz").is_err());
1405 assert!(
1406 parse_commitment_hex("2a2a").is_err(),
1407 "wrong length rejected"
1408 );
1409 }
1410}