auths_verifier/org_bundle/record.rs
1//! Off-boarding audit records — durable, signed, seal-bound evidence.
2//!
3//! Revoking an org member anchors a revocation seal in the org KEL (the
4//! provable event). These types turn that *action* into *evidence*: a typed
5//! [`OffboardingRecord`] signed by the org key and **bound to the revocation
6//! seal** — who off-boarded whom, at which **KEL position** (never
7//! wall-clock), why, and a snapshot of the role + capabilities the subject
8//! lost. The signing/storage side lives in `auths-sdk` (it needs a live
9//! keychain and registry); the wire types and the pure verification live
10//! here so any offline verifier checks a record from evidence alone.
11
12use auths_crypto::CurveType;
13use auths_keri::{Event, KeriPublicKey, Prefix, Seal};
14use serde::{Deserialize, Serialize};
15
16use super::error::OrgBundleError;
17
18/// Parse a curve tag back to a [`CurveType`]; unknown/missing defaults to P-256
19/// (the workspace default, per the wire-format rule).
20fn curve_from_tag(tag: &str) -> CurveType {
21 match tag {
22 "ed25519" => CurveType::Ed25519,
23 _ => CurveType::P256,
24 }
25}
26
27/// A typed off-boarding record — the durable evidence emitted alongside a member
28/// revocation. Ordering is by **KEL position** (`revoked_at_seq`), never wall-clock.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct OffboardingRecord {
31 /// The org's `did:keri:` (the delegator that off-boarded the member).
32 pub org_did: String,
33 /// The off-boarded member's `did:keri:`.
34 pub member_did: String,
35 /// The KEL sequence of the org's revocation event — the exact position after
36 /// which the member's authority is gone. Causal, not wall-clock.
37 pub revoked_at_seq: u128,
38 /// The SAID of the org KEL event carrying the revocation seal (anti-forgery
39 /// binding: the record is only valid against this on-KEL event).
40 pub revocation_seal_said: String,
41 /// Optional operator-supplied reason for the off-boarding.
42 pub reason: Option<String>,
43 /// The DID that authored the revocation. For a `kt=1` org this is the org DID.
44 pub operator_did: String,
45 /// The role the member held at the revocation position (what they lost).
46 pub prior_role: Option<String>,
47 /// The capabilities the member held at the revocation position (what they lost).
48 pub prior_caps: Vec<auths_keri::Capability>,
49 /// When the record was recorded (RFC 3339, injected clock). Provenance only —
50 /// authority ordering is by `revoked_at_seq`, never this timestamp.
51 pub recorded_at: String,
52}
53
54/// An [`OffboardingRecord`] plus the org's signature over its canonical form. The
55/// signature's curve travels in-band (`org_curve`) per the wire-format rule.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct SignedOffboardingRecord {
58 /// The signed record payload.
59 pub record: OffboardingRecord,
60 /// In-band curve tag for `signature` (`"ed25519"` / `"p256"`).
61 pub org_curve: String,
62 /// Hex-encoded org signature over `json_canon(record)`.
63 pub signature: String,
64}
65
66/// Locate the org KEL event that carries the revocation seal for `member_prefix`.
67///
68/// Returns `(seal_event_said, sequence)` for the latest such event, or `None` if the
69/// org never revoked the member. The revocation seal is a `Seal::Digest` whose digest
70/// equals the member prefix (the convention the revocation writer uses).
71pub fn find_revocation_event(org_kel: &[Event], member_prefix: &Prefix) -> Option<(String, u128)> {
72 let mut found = None;
73 for event in org_kel {
74 for seal in event.anchors() {
75 if let Seal::Digest { d } = seal
76 && d.as_str() == member_prefix.as_str()
77 {
78 found = Some((event.said().as_str().to_string(), event.sequence().value()));
79 }
80 }
81 }
82 found
83}
84
85/// Verify a signed off-boarding record: the org signature is valid **and** the record
86/// is bound to a matching revocation event on the org KEL.
87///
88/// Fails closed if the signature does not verify (record tampered) or no org KEL
89/// event with the record's `revocation_seal_said` revokes the member at
90/// `revoked_at_seq` (seal tampered / record forged).
91///
92/// Args:
93/// * `signed`: The signed record to verify.
94/// * `org_public_key`: The org's current verkey bytes (resolved from its KEL).
95/// * `org_curve`: The org key's curve.
96/// * `org_kel`: The org's KEL events (oldest first).
97///
98/// Usage:
99/// ```ignore
100/// verify_offboarding_record(&signed, &org_pk, org_curve, &org_kel)?;
101/// ```
102pub fn verify_offboarding_record(
103 signed: &SignedOffboardingRecord,
104 org_public_key: &[u8],
105 org_curve: CurveType,
106 org_kel: &[Event],
107) -> Result<(), OrgBundleError> {
108 if curve_from_tag(&signed.org_curve) != org_curve {
109 return Err(OrgBundleError::RecordInvalid(
110 "offboarding record curve tag does not match the org key curve".to_string(),
111 ));
112 }
113
114 let canonical = json_canon::to_string(&signed.record)
115 .map_err(|e| OrgBundleError::Canonicalize(format!("offboarding record: {e}")))?;
116 let sig = hex::decode(&signed.signature)
117 .map_err(|e| OrgBundleError::RecordInvalid(format!("decode offboarding signature: {e}")))?;
118 let key = KeriPublicKey::from_verkey_bytes(org_public_key, org_curve)
119 .map_err(|e| OrgBundleError::RecordInvalid(format!("invalid org public key: {e}")))?;
120 key.verify_signature(canonical.as_bytes(), &sig)
121 .map_err(|e| {
122 OrgBundleError::RecordInvalid(format!("offboarding signature invalid: {e}"))
123 })?;
124
125 let member_prefix = Prefix::new_unchecked(
126 signed
127 .record
128 .member_did
129 .strip_prefix("did:keri:")
130 .unwrap_or(&signed.record.member_did)
131 .to_string(),
132 );
133 match find_revocation_event(org_kel, &member_prefix) {
134 Some((said, seq))
135 if said == signed.record.revocation_seal_said
136 && seq == signed.record.revoked_at_seq =>
137 {
138 Ok(())
139 }
140 _ => Err(OrgBundleError::RecordInvalid(
141 "offboarding record is not bound to a matching org KEL revocation seal".to_string(),
142 )),
143 }
144}