Skip to main content

auths_verifier/org_bundle/
verify.rs

1//! Offline verification of an air-gapped org bundle — a pure, zero-network function
2//! of the bundle's contents.
3//!
4//! Reproduces the live org/member provenance verdict from an
5//! [`AirGappedOrgBundle`] with the network cable unplugged: it recomputes every
6//! bundled event's SAID (tamper-evident), authenticates every event's signature
7//! against the controlling key-state (RT-002), confirms the org is a **pinned**
8//! root, flags KEL duplicity, and classifies a member's authority at a signing
9//! position **by KEL position, never wall-clock**.
10//!
11//! Fail-closed: a tampered event (SAID mismatch), a delegated member whose KEL is
12//! missing, or a queried member with no delegation seal each yield a **named hard
13//! error**, never "valid." A wrong/non-delegating pinned root is reported as
14//! `root_pinned = false` (unauthorized), not an error.
15
16use auths_keri::{Event, Prefix, Seal, verify_event_said};
17use serde::Serialize;
18
19use super::bundle::{AirGappedOrgBundle, BundledKel};
20use super::error::OrgBundleError;
21use super::record::find_revocation_event;
22use crate::duplicity::{KelEventRef, detect_duplicity};
23use crate::types::IdentityDID;
24
25/// Maximum accepted JSON input for the JSON/WASM surface (16 MiB) — a bundle
26/// carries whole KELs, so the ceiling is higher than the attestation contract's.
27pub const MAX_BUNDLE_JSON_BYTES: usize = 16 * 1024 * 1024;
28
29/// A member's authority at the moment an artifact was signed — a closed sum ordered
30/// strictly by **KEL position** (never wall-clock).
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
32#[serde(tag = "authority_at_signing", rename_all = "snake_case")]
33pub enum AuthorityAtSigning {
34    /// The member's authority was live at the signing position — the artifact was
35    /// signed strictly before the revocation, or the member was never revoked.
36    AuthorizedBeforeRevocation,
37    /// The artifact was signed at or after the org revoked the member, by KEL
38    /// position. Carries the exact revocation position.
39    RejectedAfterRevocation {
40        /// The KEL position at which the org anchored the revocation.
41        #[serde(with = "u128_str")]
42        revoked_at: u128,
43    },
44    /// The org revoked the member but the artifact carries no in-band signing
45    /// position, so it cannot be ordered — conservatively rejected (mirrors the
46    /// verifier's no-position default).
47    RejectedRevokedPositionUnknown {
48        /// The KEL position at which the org anchored the revocation.
49        #[serde(with = "u128_str")]
50        revoked_at: u128,
51    },
52    /// The org never delegated this member — there is no authority to classify.
53    NeverDelegated,
54}
55
56/// (De)serialize a `u128` KEL position as a decimal string.
57///
58/// JSON numbers lose precision above 2^53, and serde's internally-tagged-enum
59/// buffer cannot round-trip 128-bit integers — so KEL positions travel as strings
60/// (the same convention [`auths_keri::KeriSequence`] uses for its hex form).
61mod u128_str {
62    use serde::{Deserialize, Deserializer, Serializer};
63
64    pub fn serialize<S: Serializer>(value: &u128, serializer: S) -> Result<S::Ok, S::Error> {
65        serializer.serialize_str(&value.to_string())
66    }
67
68    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u128, D::Error> {
69        let s = String::deserialize(deserializer)?;
70        s.parse().map_err(serde::de::Error::custom)
71    }
72}
73
74/// The result of verifying an air-gapped org bundle offline.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76pub struct OfflineVerifyReport {
77    /// The org the bundle is for.
78    pub org_did: IdentityDID,
79    /// The org KEL position the bundle (and therefore this verdict) is verified
80    /// as-of — "verified as-of KEL position X."
81    pub as_of_org_seq: u128,
82    /// Whether the org is in the caller's pinned trust roots. `false` =
83    /// unauthorized (the verdict cannot be trusted), not an error.
84    pub root_pinned: bool,
85    /// Whether the org KEL shows duplicity (same seq, divergent SAIDs). Flagged,
86    /// never silently accepted.
87    pub duplicity_detected: bool,
88    /// The queried member's authority at the signing position, when a member +
89    /// position were supplied. Ordered by KEL position, never wall-clock.
90    pub authority: Option<AuthorityAtSigning>,
91}
92
93/// Recompute and check every event's SAID in a bundled KEL (tamper detection).
94fn check_kel_integrity(kel: &BundledKel) -> Result<(), OrgBundleError> {
95    for event in &kel.events {
96        verify_event_said(event).map_err(|e| OrgBundleError::Integrity {
97            id: kel.prefix.as_str().to_string(),
98            reason: e.to_string(),
99        })?;
100    }
101    Ok(())
102}
103
104/// Authenticate a bundled KEL (RT-002): verify every event is SIGNED by the
105/// controlling key-state, not just SAID-correct. Reconstructs `SignedEvent`s from
106/// the parallel `events` × hex `attachments` and replays them through
107/// `validate_signed_kel`. Fails closed on a length mismatch, an unparseable
108/// attachment, or a signature that doesn't verify.
109///
110/// Returns the authenticated [`auths_keri::KeyState`] — the only trust-rooted
111/// source of the controller's *current* verkey available offline (e.g. to verify
112/// a DSSE envelope signed by the org whose KEL the evidence embeds).
113///
114/// This is the shared ecosystem primitive: a downstream verifier (CI gate,
115/// browser widget, third-party audit tool) holding a [`BundledKel`] gets the
116/// controller's authenticated key state from evidence alone in one call, rather
117/// than reconstructing `SignedEvent`s and replaying `validate_signed_kel` itself.
118/// For an [`AirGappedOrgBundle`], prefer
119/// [`AirGappedOrgBundle::authenticated_org_state`].
120pub fn authenticate_bundled_kel(
121    kel: &BundledKel,
122    lookup: Option<&dyn auths_keri::DelegatorKelLookup>,
123) -> Result<auths_keri::KeyState, OrgBundleError> {
124    let integrity_err = |reason: String| OrgBundleError::Integrity {
125        id: kel.prefix.as_str().to_string(),
126        reason,
127    };
128    if kel.attachments.len() != kel.events.len() {
129        return Err(integrity_err(format!(
130            "{} events but {} signature attachments — cannot authenticate",
131            kel.events.len(),
132            kel.attachments.len()
133        )));
134    }
135    let mut signed = Vec::with_capacity(kel.events.len());
136    for (event, att_hex) in kel.events.iter().zip(kel.attachments.iter()) {
137        let bytes =
138            hex::decode(att_hex).map_err(|e| integrity_err(format!("non-hex attachment: {e}")))?;
139        // A delegated (dip/drt) event's attachment is `-A <sig> ++ -G <source seal>`;
140        // a plain event's is just `-A <sig>`. `parse_delegated_attachment` handles both.
141        let (sigs, seals) = auths_keri::parse_delegated_attachment(&bytes)
142            .map_err(|e| integrity_err(format!("unparseable attachment: {e}")))?;
143        // A dip/drt JSON body carries no source seal (it lives in the attachment);
144        // restore it so the delegation binding can be authenticated.
145        let event = rehydrate_event_source_seal(event.clone(), seals.into_iter().next());
146        signed.push(auths_keri::SignedEvent::new(event, sigs));
147    }
148    auths_keri::validate_signed_kel(&signed, lookup)
149        .map_err(|e| integrity_err(format!("KEL signature authentication failed (RT-002): {e}")))
150}
151
152/// Re-attach a delegated event's source seal from its parsed attachment — the JSON
153/// body of a `dip`/`drt` carries none (it lives in the `-G` group). Mirrors the
154/// storage layer's `rehydrate_source_seal`.
155fn rehydrate_event_source_seal(event: Event, seal: Option<auths_keri::SourceSeal>) -> Event {
156    let Some(seal) = seal else {
157        return event;
158    };
159    match event {
160        Event::Dip(mut e) => {
161            e.source_seal = Some(seal);
162            Event::Dip(e)
163        }
164        Event::Drt(mut e) => {
165            e.source_seal = Some(seal);
166            Event::Drt(e)
167        }
168        other => other,
169    }
170}
171
172/// Does the org KEL anchor a delegation `KeyEvent` seal for `member_prefix`?
173fn is_delegated(org_kel: &[Event], member_prefix: &Prefix) -> bool {
174    org_kel.iter().any(|event| {
175        event.anchors().iter().any(
176            |seal| matches!(seal, Seal::KeyEvent { i, .. } if i.as_str() == member_prefix.as_str()),
177        )
178    })
179}
180
181/// Classify a member's authority at `signed_at` from an air-gapped bundle's org KEL —
182/// the offline mirror of the live registry classifier, for re-deriving compliance
183/// evidence-pack rows with zero network.
184///
185/// Args:
186/// * `bundle`: The air-gapped org bundle (its org KEL is the authority source).
187/// * `member_prefix`: The member to classify.
188/// * `signed_at`: The artifact's in-band signing position, if any.
189///
190/// Usage:
191/// ```ignore
192/// let verdict = classify_authority_in_bundle(&bundle, &member, Some(41));
193/// ```
194pub fn classify_authority_in_bundle(
195    bundle: &AirGappedOrgBundle,
196    member_prefix: &Prefix,
197    signed_at: Option<u128>,
198) -> AuthorityAtSigning {
199    classify_from_bundle(&bundle.org_kel.events, member_prefix, signed_at)
200}
201
202/// Classify a member's authority at `signed_at`, read purely from the bundle's org
203/// KEL.
204fn classify_from_bundle(
205    org_kel: &[Event],
206    member_prefix: &Prefix,
207    signed_at: Option<u128>,
208) -> AuthorityAtSigning {
209    if !is_delegated(org_kel, member_prefix) {
210        return AuthorityAtSigning::NeverDelegated;
211    }
212    match find_revocation_event(org_kel, member_prefix) {
213        None => AuthorityAtSigning::AuthorizedBeforeRevocation,
214        Some((_, revoked_at)) => match signed_at {
215            None => AuthorityAtSigning::RejectedRevokedPositionUnknown { revoked_at },
216            Some(seq) if seq < revoked_at => AuthorityAtSigning::AuthorizedBeforeRevocation,
217            Some(_) => AuthorityAtSigning::RejectedAfterRevocation { revoked_at },
218        },
219    }
220}
221
222/// Verify an air-gapped org bundle offline.
223///
224/// Pure and network-free. Checks bundle integrity (every event's SAID),
225/// authenticates every event's signature (RT-002), confirms the org is pinned,
226/// flags org-KEL duplicity, and — when `query` supplies a member and optional
227/// signing position — classifies that member's authority by KEL position.
228///
229/// Fail-closed errors (never "valid"): [`OrgBundleError::Integrity`] (a tampered
230/// event), [`OrgBundleError::MissingMemberKel`] (a delegated member's KEL is
231/// absent), [`OrgBundleError::MissingDelegatorSeal`] (a queried member was never
232/// delegated).
233///
234/// Args:
235/// * `bundle`: The air-gapped bundle to verify.
236/// * `pinned_roots`: The caller's pinned trust roots (e.g. from `.auths/roots`).
237/// * `query`: Optional `(member_prefix, signed_at)` to classify a specific artifact.
238///
239/// Usage:
240/// ```ignore
241/// let report = verify_org_bundle(&bundle, &roots, Some((&member, Some(41))))?;
242/// assert!(report.root_pinned);
243/// ```
244pub fn verify_org_bundle(
245    bundle: &AirGappedOrgBundle,
246    pinned_roots: &[IdentityDID],
247    query: Option<(&Prefix, Option<u128>)>,
248) -> Result<OfflineVerifyReport, OrgBundleError> {
249    // 1. Integrity + AUTHENTICATION (RT-002): every bundled event must self-address
250    //    (SAID) AND be signed by the controlling key-state — not just structurally
251    //    valid. The org KEL is the root of trust (no delegator); member KELs are
252    //    authenticated against the org as delegator.
253    check_kel_integrity(&bundle.org_kel)?;
254    authenticate_bundled_kel(&bundle.org_kel, None)?;
255    let org_lookup = auths_keri::KelSealIndex::from_events(&bundle.org_kel.events);
256    for member_kel in &bundle.member_kels {
257        check_kel_integrity(member_kel)?;
258        authenticate_bundled_kel(member_kel, Some(&org_lookup))?;
259    }
260
261    // 2. Completeness: every member the org delegated must ship its own KEL.
262    for event in &bundle.org_kel.events {
263        for seal in event.anchors() {
264            if let Seal::KeyEvent { i, .. } = seal {
265                let present = bundle
266                    .member_kels
267                    .iter()
268                    .any(|m| m.prefix.as_str() == i.as_str());
269                if !present {
270                    return Err(OrgBundleError::MissingMemberKel {
271                        member: format!("did:keri:{}", i.as_str()),
272                    });
273                }
274            }
275        }
276    }
277
278    // 3. Trust: is the org a pinned root? (false = unauthorized, not an error.)
279    let root_pinned = pinned_roots
280        .iter()
281        .any(|r| r.as_str() == bundle.org_did.as_str());
282
283    // 4. Duplicity: same-seq divergent SAIDs on the org KEL — flag, never accept.
284    let org_did_str = bundle.org_did.as_str().to_string();
285    let refs: Vec<KelEventRef<'_>> = bundle
286        .org_kel
287        .events
288        .iter()
289        .map(|e| KelEventRef {
290            prefix: org_did_str.as_str(),
291            seq: e.sequence().value() as u64,
292            said: e.said().as_str(),
293        })
294        .collect();
295    let duplicity_detected = detect_duplicity(&refs).is_diverging();
296
297    // 5. Authority classification for the queried member, by KEL position.
298    let authority = match query {
299        Some((member_prefix, signed_at)) => {
300            if !is_delegated(&bundle.org_kel.events, member_prefix) {
301                return Err(OrgBundleError::MissingDelegatorSeal {
302                    member: format!("did:keri:{}", member_prefix.as_str()),
303                });
304            }
305            Some(classify_from_bundle(
306                &bundle.org_kel.events,
307                member_prefix,
308                signed_at,
309            ))
310        }
311        None => None,
312    };
313
314    Ok(OfflineVerifyReport {
315        org_did: bundle.org_did.clone(),
316        as_of_org_seq: bundle.built_at_org_seq,
317        root_pinned,
318        duplicity_detected,
319        authority,
320    })
321}
322
323// ── JSON contract (the WASM/FFI-facing form) ───────────────────────────────
324
325/// The tagged verdict envelope for [`verify_org_bundle_json`].
326#[derive(Serialize)]
327#[serde(tag = "kind", rename_all = "camelCase")]
328enum BundleVerdictJson {
329    /// Verification ran to completion; the report carries the verdict axes.
330    #[serde(rename = "report")]
331    Report {
332        /// The offline verification report.
333        report: OfflineVerifyReport,
334    },
335    /// Verification failed closed (tampered bundle, incomplete bundle, bad input).
336    #[serde(rename = "error")]
337    Error {
338        /// The stable `AUTHS-Exxxx` code.
339        code: String,
340        /// Human-readable detail.
341        message: String,
342    },
343}
344
345/// A last-resort verdict used only if envelope serialization itself fails.
346const SERIALIZE_FALLBACK: &str =
347    r#"{"kind":"error","code":"AUTHS-E2204","message":"verdict serialization failed"}"#;
348
349fn envelope_to_string(envelope: &BundleVerdictJson) -> String {
350    serde_json::to_string(envelope).unwrap_or_else(|_| SERIALIZE_FALLBACK.to_string())
351}
352
353fn error_envelope(e: &OrgBundleError) -> String {
354    use auths_crypto::AuthsErrorInfo;
355    envelope_to_string(&BundleVerdictJson::Error {
356        code: e.error_code().to_string(),
357        message: e.to_string(),
358    })
359}
360
361/// Verify an air-gapped org bundle from its JSON wire forms — the
362/// string-in/string-out contract the WASM surface exposes.
363///
364/// Panic-free and synchronous: malformed or oversize input returns a tagged
365/// `error` envelope, never an exception. The verdict is a discriminated union
366/// (`kind`: `"report"` | `"error"`), never a bare bool.
367///
368/// Args:
369/// * `bundle_json`: The [`AirGappedOrgBundle`] JSON (the `.auths-offline` file).
370/// * `pinned_roots_json`: JSON array of the verifier's pinned `did:keri:` roots.
371/// * `member_did`: Optional member to classify (`did:keri:` or bare prefix).
372/// * `signed_at`: Optional in-band signing KEL position, as a decimal string.
373///
374/// Usage:
375/// ```ignore
376/// let verdict = verify_org_bundle_json(&bundle, r#"["did:keri:EOrg"]"#, None, None);
377/// ```
378pub fn verify_org_bundle_json(
379    bundle_json: &str,
380    pinned_roots_json: &str,
381    member_did: Option<&str>,
382    signed_at: Option<&str>,
383) -> String {
384    match verify_org_bundle_json_inner(bundle_json, pinned_roots_json, member_did, signed_at) {
385        Ok(report) => envelope_to_string(&BundleVerdictJson::Report { report }),
386        Err(e) => error_envelope(&e),
387    }
388}
389
390fn verify_org_bundle_json_inner(
391    bundle_json: &str,
392    pinned_roots_json: &str,
393    member_did: Option<&str>,
394    signed_at: Option<&str>,
395) -> Result<OfflineVerifyReport, OrgBundleError> {
396    if bundle_json.len() > MAX_BUNDLE_JSON_BYTES {
397        return Err(OrgBundleError::Parse(format!(
398            "bundle JSON too large: {} bytes, max {}",
399            bundle_json.len(),
400            MAX_BUNDLE_JSON_BYTES
401        )));
402    }
403    let bundle = AirGappedOrgBundle::from_json(bundle_json)?;
404    let pinned_roots: Vec<IdentityDID> = serde_json::from_str(pinned_roots_json)
405        .map_err(|e| OrgBundleError::Parse(format!("pinned roots: {e}")))?;
406
407    let member_prefix = member_did
408        .map(|m| {
409            Prefix::new(m.strip_prefix("did:keri:").unwrap_or(m).to_string())
410                .map_err(|e| OrgBundleError::Parse(format!("member prefix: {e}")))
411        })
412        .transpose()?;
413    let signed_at = signed_at
414        .map(|s| {
415            s.parse::<u128>()
416                .map_err(|e| OrgBundleError::Parse(format!("signed_at: {e}")))
417        })
418        .transpose()?;
419
420    let query = member_prefix.as_ref().map(|p| (p, signed_at));
421    verify_org_bundle(&bundle, &pinned_roots, query)
422}