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}