Skip to main content

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}