auths_verifier/commit_kel.rs
1//! KEL-native commit verdict — the heart of Epic B.
2//!
3//! Given a commit, the signer's device KEL, the root KEL, and the pinned trusted
4//! roots, decide whether the commit is authorized **purely by replaying the log**:
5//! the device is a delegated identifier the root anchored and has not revoked, and
6//! the commit's SSH signature was made by the device's current key — all verified
7//! in-process (no `ssh-keygen`, no `allowed_signers`). Every failure is a
8//! distinguishable [`CommitVerdict`], never a bare "invalid signature".
9
10use auths_crypto::CryptoProvider;
11use auths_keri::witness::{NoWitnessReceipts, WitnessReceiptLookup};
12use auths_keri::{
13 CesrKey, Event, KelSealIndex, KeriPublicKey, Prefix, Seal, TrustedKel, WitnessedReplay,
14 validate_delegation,
15};
16
17use crate::commit::{extract_ssh_signature, verify_commit_signature};
18use crate::commit_error::CommitVerificationError;
19use crate::core::DevicePublicKey;
20use crate::duplicity::{KelEventRef, detect_duplicity};
21use crate::freshness::{Freshness, FreshnessEvidence, FreshnessPolicy};
22use crate::ssh_sig::parse_sshsig_pem;
23
24/// The outcome of KEL-native commit verification. Distinguishable so the CLI/UX can
25/// explain *why* a commit failed (never a generic `InvalidSignature`).
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum CommitVerdict {
28 /// Authorized: the signer is a non-revoked delegate of a pinned root (or the
29 /// pinned root itself) and the SSH signature matches its current key.
30 Valid {
31 /// The verified signer `did:keri:`.
32 signer_did: String,
33 /// The root `did:keri:` it chains to.
34 root_did: String,
35 /// True if the root KEL shows a fork (non-fatal warning — trust-on-first-sight).
36 duplicitous_root: bool,
37 /// The signer-KEL tip sequence this verdict was decided against.
38 as_of: u128,
39 /// How fresh this verdict is under the verifier's freshness policy (ADR 009). The
40 /// positional verifier sets `Unknown`; a caller with a freshness signal upgrades it
41 /// via [`CommitVerdict::with_freshness`].
42 freshness: Freshness,
43 },
44 /// The commit carries no SSH signature.
45 Unsigned,
46 /// The SSH signature did not validate (tampered commit, wrong namespace, or bad sig).
47 SshSignatureInvalid,
48 /// A PGP-signed commit (out of scope).
49 GpgUnsupported,
50 /// The signer's device KEL failed to replay/validate.
51 DeviceKelInvalid(String),
52 /// The root KEL failed to replay/validate.
53 RootKelInvalid(String),
54 /// The root identity is not in the pinned trusted-root set (`.auths/roots`).
55 RootNotPinned(String),
56 /// The root identity's KEL is abandoned.
57 RootAbandoned,
58 /// The device is not delegated by the claimed/pinned root.
59 NotDelegatedByClaimedRoot {
60 /// The device's `did:keri:`.
61 device_did: String,
62 /// The root we verified against.
63 root_did: String,
64 },
65 /// The root never anchored the device's delegated inception.
66 DelegationSealNotFound,
67 /// The root has revoked this device/agent's delegation and the commit carries
68 /// no in-band signing position, so it cannot be ordered against the revocation
69 /// (conservative flat rejection — preserves the no-position default).
70 DeviceRevoked,
71 /// The commit was signed **at or after** the delegator anchored the revocation
72 /// (its in-band `Auths-Anchor-Seq` is ≥ the revocation's KEL position). Distinct
73 /// from [`CommitVerdict::DeviceRevoked`]: a commit signed *before* the revocation
74 /// stays [`CommitVerdict::Valid`] — revocation is ordered by KEL position, never
75 /// wall-clock, so legitimate prior history is not retroactively invalidated.
76 SignedAfterRevocation {
77 /// The signer's `did:keri:`.
78 signer_did: String,
79 /// The signing position claimed in-band (`Auths-Anchor-Seq`).
80 signed_at: u128,
81 /// The KEL position at which the delegator anchored the revocation.
82 revoked_at: u128,
83 },
84 /// The agent signed exercising a capability outside its delegator-anchored
85 /// scope (the delegator never granted it). Scope is advisory authorization
86 /// anchored by the delegator (the ACDC upgrade is Epic F).
87 OutsideAgentScope {
88 /// The signer's `did:keri:`.
89 signer_did: String,
90 /// The capability the commit claimed that the scope does not grant.
91 capability: String,
92 },
93 /// The agent signed at/after its delegator-anchored expiry. Checked against the
94 /// signing time via an injected `now` (no wall-clock in the verifier).
95 AgentExpired {
96 /// The signer's `did:keri:`.
97 signer_did: String,
98 /// The expiry instant (Unix epoch seconds) the delegator anchored.
99 expired_at: i64,
100 /// The signing time the commit was checked against (injected `now`).
101 signed_at: i64,
102 },
103 /// The SSH signer key is not the device's current key (and not a known prior key).
104 SignerKeyMismatch,
105 /// The SSH signer key is a *superseded* device key (the device rotated since signing).
106 SignedBySupersededKey,
107 /// Under `--require-witnesses`, the signer's root KEL did not reach M-of-N
108 /// witness quorum for an establishment event (fail-closed).
109 WitnessQuorumNotMet {
110 /// The root `did:keri:` whose KEL is under-quorum.
111 root_did: String,
112 /// Distinct valid witness receipts collected.
113 collected: usize,
114 /// Receipts required by the in-force backer threshold.
115 required: usize,
116 },
117}
118
119/// Verifier-side witness policy — independent of the signer's own `WitnessPolicy`.
120///
121/// A verifier cannot trust the signer's self-declared policy (it lives in the
122/// signer's config), so it sets its own.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
124pub enum VerifierWitnessPolicy {
125 /// Under-quorum signer key-state is a non-fatal warning (preserves the
126 /// Stage-1 trust-on-first-sight caveat during rollout). The default.
127 #[default]
128 Warn,
129 /// Under-quorum signer key-state fails verification (fail-closed).
130 RequireWitnesses,
131}
132
133/// Witness-quorum status of a verified signer KEL, surfaced for CLI display (D.9).
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum WitnessGateStatus {
136 /// The signer KEL designates no witnesses (`bt=0`); none required.
137 NotRequired,
138 /// Witness quorum was met.
139 Met,
140 /// Quorum was not met but accepted anyway under [`VerifierWitnessPolicy::Warn`].
141 UnderQuorum {
142 /// Distinct valid receipts collected.
143 collected: usize,
144 /// Receipts required by the in-force backer threshold.
145 required: usize,
146 },
147}
148
149/// A commit verdict paired with the signer KEL's witness-quorum status.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct WitnessedVerdict {
152 /// The commit authorization verdict.
153 pub verdict: CommitVerdict,
154 /// Witness-quorum status of the signer's (root) KEL.
155 pub witness: WitnessGateStatus,
156}
157
158impl CommitVerdict {
159 /// Whether the commit is authorized (a `Valid` verdict, regardless of the
160 /// non-fatal duplicity warning).
161 pub fn is_valid(&self) -> bool {
162 matches!(self, CommitVerdict::Valid { .. })
163 }
164
165 /// This verdict's freshness (ADR 009). Only meaningful for `Valid`; a non-`Valid`
166 /// verdict reports [`Freshness::Stale`] since it is never trusted regardless.
167 pub fn freshness(&self) -> Freshness {
168 match self {
169 CommitVerdict::Valid { freshness, .. } => *freshness,
170 _ => Freshness::Stale,
171 }
172 }
173
174 /// The KEL position a `Valid` verdict was verified as-of (the `{as_of, freshness}` pair,
175 /// ADR 009), or `None` when the verdict is not `Valid`.
176 pub fn as_of(&self) -> Option<u128> {
177 match self {
178 CommitVerdict::Valid { as_of, .. } => Some(*as_of),
179 _ => None,
180 }
181 }
182
183 /// Re-classify a `Valid` verdict's freshness against a policy and the evidence of a
184 /// fresher source (a bundle age, or a fresher signer-KEL tip). The positional verifier
185 /// emits `Unknown`; the relying party upgrades it here once it has a freshness oracle.
186 /// A no-op on non-`Valid` verdicts.
187 ///
188 /// Args:
189 /// * `policy`: the relying party's freshness policy.
190 /// * `evidence`: what the relying party knows about a source fresher than the slice.
191 pub fn with_freshness(self, policy: &FreshnessPolicy, evidence: FreshnessEvidence) -> Self {
192 match self {
193 CommitVerdict::Valid {
194 signer_did,
195 root_did,
196 duplicitous_root,
197 as_of,
198 ..
199 } => CommitVerdict::Valid {
200 signer_did,
201 root_did,
202 duplicitous_root,
203 as_of,
204 freshness: policy.classify(evidence),
205 },
206 other => other,
207 }
208 }
209
210 /// Whether this verdict is trusted under a freshness policy: it is `Valid`, its root KEL is
211 /// **not duplicitous** (a fork fails closed — the relying party cannot tell which branch is
212 /// real), AND its freshness clears the policy. A bare `Valid` is never trusted without
213 /// freshness — the relying party's policy (not the signer) decides the tolerance (ADR 009).
214 ///
215 /// Args:
216 /// * `policy`: the relying party's freshness policy.
217 pub fn is_trusted(&self, policy: &FreshnessPolicy) -> bool {
218 if matches!(
219 self,
220 CommitVerdict::Valid {
221 duplicitous_root: true,
222 ..
223 }
224 ) {
225 return false;
226 }
227 self.is_valid() && policy.trusts(self.freshness())
228 }
229
230 /// A stable, machine-readable code for this verdict, suitable for the `status`
231 /// field of structured output. Lets a consumer attribute a rejection to its
232 /// specific cause (e.g. `outside-agent-scope`) without parsing the human
233 /// `error` string. These codes are part of the CLI's machine contract — keep
234 /// them stable.
235 pub fn code(&self) -> &'static str {
236 match self {
237 CommitVerdict::Valid { .. } => "valid",
238 CommitVerdict::Unsigned => "unsigned",
239 CommitVerdict::SshSignatureInvalid => "ssh-signature-invalid",
240 CommitVerdict::GpgUnsupported => "gpg-unsupported",
241 CommitVerdict::DeviceKelInvalid(_) => "device-kel-invalid",
242 CommitVerdict::RootKelInvalid(_) => "root-kel-invalid",
243 CommitVerdict::RootNotPinned(_) => "root-not-pinned",
244 CommitVerdict::RootAbandoned => "root-abandoned",
245 CommitVerdict::NotDelegatedByClaimedRoot { .. } => "not-delegated-by-claimed-root",
246 CommitVerdict::DelegationSealNotFound => "delegation-seal-not-found",
247 CommitVerdict::DeviceRevoked => "device-revoked",
248 CommitVerdict::SignedAfterRevocation { .. } => "signed-after-revocation",
249 CommitVerdict::OutsideAgentScope { .. } => "outside-agent-scope",
250 CommitVerdict::AgentExpired { .. } => "agent-expired",
251 CommitVerdict::SignerKeyMismatch => "signer-key-mismatch",
252 CommitVerdict::SignedBySupersededKey => "signed-by-superseded-key",
253 CommitVerdict::WitnessQuorumNotMet { .. } => "witness-quorum-not-met",
254 }
255 }
256}
257
258/// The KEL position (root sequence) at which the delegator anchored a revocation
259/// (`Seal::Digest{d == device_prefix}`) for the device/agent, or `None` if not
260/// revoked. KERI carries no wall-clock, so revocation is ordered by this position.
261///
262/// Args:
263/// * `root_kel`: The delegator's KEL.
264/// * `device_prefix`: The delegated identifier's prefix.
265fn revocation_position(root_kel: &[Event], device_prefix: &Prefix) -> Option<u128> {
266 root_kel.iter().find_map(|event| {
267 let revokes = event
268 .anchors()
269 .iter()
270 .any(|seal| matches!(seal, Seal::Digest { d } if d.as_str() == device_prefix.as_str()));
271 revokes.then(|| event.sequence().value())
272 })
273}
274
275/// The CESR commit trailer key carrying the signer's in-band KEL position — the
276/// delegator-anchoring sequence in force when the commit was signed. Lets the
277/// verifier order a commit against a later revocation by KEL position.
278pub const ANCHOR_SEQ_TRAILER: &str = "Auths-Anchor-Seq";
279
280/// Format the signing-position commit trailer (`Auths-Anchor-Seq: <seq>`).
281///
282/// Args:
283/// * `seq`: The delegator-anchoring sequence in force at signing.
284///
285/// Usage:
286/// ```
287/// use auths_verifier::anchor_seq_trailer;
288/// assert_eq!(anchor_seq_trailer(7), "Auths-Anchor-Seq: 7");
289/// ```
290pub fn anchor_seq_trailer(seq: u128) -> String {
291 format!("{ANCHOR_SEQ_TRAILER}: {seq}")
292}
293
294/// Parse the signer's in-band KEL position from a commit's `Auths-Anchor-Seq`
295/// trailer, or `None` if absent/unparseable.
296///
297/// Args:
298/// * `commit_bytes`: The raw signed commit content.
299fn parse_anchor_seq(commit_bytes: &[u8]) -> Option<u128> {
300 let text = std::str::from_utf8(commit_bytes).ok()?;
301 text.lines().find_map(|line| {
302 let rest = line.trim().strip_prefix(ANCHOR_SEQ_TRAILER)?;
303 rest.trim_start()
304 .strip_prefix(':')?
305 .trim()
306 .parse::<u128>()
307 .ok()
308 })
309}
310
311/// The commit trailer key naming the root identity (`Auths-Id: did:keri:…`).
312pub const ID_TRAILER: &str = "Auths-Id";
313
314/// The commit trailer key naming the signing device/agent (`Auths-Device: …`).
315pub const DEVICE_TRAILER: &str = "Auths-Device";
316
317/// Extract `(root_did, device_did)` from a commit's `Auths-Id` / `Auths-Device`
318/// trailers. Returns `None` when either trailer is absent (a commit not signed
319/// by `auths`). Keys match case-insensitively; the last occurrence wins (git
320/// trailer semantics). These are in-band *claims* that select which KELs to
321/// replay — the proof is always the signature + the pinned-root check.
322///
323/// Args:
324/// * `raw_commit`: The raw git commit object (headers + message).
325///
326/// Usage:
327/// ```
328/// use auths_verifier::commit_signer_trailers;
329/// let commit = "tree abc\n\nfix\n\nAuths-Id: did:keri:Er\nAuths-Device: did:keri:Ed\n";
330/// assert_eq!(
331/// commit_signer_trailers(commit),
332/// Some(("did:keri:Er".into(), "did:keri:Ed".into()))
333/// );
334/// ```
335pub fn commit_signer_trailers(raw_commit: &str) -> Option<(String, String)> {
336 let message = raw_commit
337 .split_once("\n\n")
338 .map(|(_, m)| m)
339 .unwrap_or(raw_commit);
340 let find = |key: &str| {
341 message.lines().rev().find_map(|line| {
342 let (k, v) = line.split_once(':')?;
343 k.trim()
344 .eq_ignore_ascii_case(key)
345 .then(|| v.trim().to_string())
346 })
347 };
348 Some((find(ID_TRAILER)?, find(DEVICE_TRAILER)?))
349}
350
351/// The CESR commit trailer key carrying the capability the commit exercises, checked
352/// against the agent's delegator-anchored scope.
353pub const SCOPE_TRAILER: &str = "Auths-Scope";
354
355/// Format a scope-claim commit trailer (`Auths-Scope: <cap>[,<cap>…]`).
356///
357/// Args:
358/// * `capabilities`: The capabilities the commit exercises.
359///
360/// Usage:
361/// ```
362/// use auths_verifier::scope_trailer;
363/// assert_eq!(scope_trailer(&["sign_commit".into()]), "Auths-Scope: sign_commit");
364/// ```
365pub fn scope_trailer(capabilities: &[String]) -> String {
366 format!("{SCOPE_TRAILER}: {}", capabilities.join(","))
367}
368
369/// Parse the capabilities a commit claims from its `Auths-Scope` trailer.
370fn parse_scope_claim(commit_bytes: &[u8]) -> Vec<String> {
371 let Ok(text) = std::str::from_utf8(commit_bytes) else {
372 return Vec::new();
373 };
374 text.lines()
375 .find_map(|line| {
376 line.trim()
377 .strip_prefix(SCOPE_TRAILER)?
378 .trim_start()
379 .strip_prefix(':')
380 .map(|rest| {
381 rest.trim()
382 .split(',')
383 .filter(|c| !c.is_empty())
384 .map(|c| c.trim().to_string())
385 .collect::<Vec<_>>()
386 })
387 })
388 .unwrap_or_default()
389}
390
391/// The agent's latest delegator-anchored scope in `root_kel`, or `None`.
392fn read_agent_scope_from_kel(
393 root_kel: &[Event],
394 agent_prefix: &Prefix,
395) -> Option<auths_keri::AgentScope> {
396 let mut found = None;
397 for event in root_kel {
398 for seal in event.anchors() {
399 if let Seal::Digest { d } = seal
400 && let Some((prefix, scope)) = auths_keri::decode_agent_scope(d.as_str())
401 && prefix == agent_prefix.as_str()
402 {
403 found = Some(scope);
404 }
405 }
406 }
407 found
408}
409
410/// How a commit's signing position orders against a revocation.
411#[derive(Debug, Clone, Copy, PartialEq, Eq)]
412enum RevocationOrdering {
413 /// The delegation was never revoked.
414 NotRevoked,
415 /// The commit was signed strictly before the revocation's KEL position — valid.
416 SignedBefore,
417 /// The commit was signed at/after the revocation's KEL position — rejected.
418 SignedAfter {
419 /// The signing position claimed in-band.
420 signed_at: u128,
421 /// The revocation's KEL position.
422 revoked_at: u128,
423 },
424 /// Revoked, but the commit carries no in-band position — cannot be ordered.
425 RevokedUnknownPosition,
426}
427
428/// Order a commit's in-band signing position against the revocation position.
429///
430/// NOTE (RT-003): the in-band position is a signer-chosen trailer, so the *caller*
431/// no longer treats [`RevocationOrdering::SignedBefore`] as acceptance — a
432/// currently-revoked delegate fails closed regardless of the claimed position
433/// until an independent ordering source (witness receipt / transparency log /
434/// signed git-history reachability) exists. This function still computes the
435/// ordering so that stronger fix can later accept a `SignedBefore` commit when it
436/// is independently corroborated.
437fn classify_revocation(
438 signing_anchor: Option<u128>,
439 revocation: Option<u128>,
440) -> RevocationOrdering {
441 match (revocation, signing_anchor) {
442 (None, _) => RevocationOrdering::NotRevoked,
443 (Some(_), None) => RevocationOrdering::RevokedUnknownPosition,
444 (Some(rev), Some(sign)) if sign < rev => RevocationOrdering::SignedBefore,
445 (Some(rev), Some(sign)) => RevocationOrdering::SignedAfter {
446 signed_at: sign,
447 revoked_at: rev,
448 },
449 }
450}
451
452/// The establishment keys (`k[]`) across a device KEL, parsed to device pubkeys —
453/// used to tell a *superseded* signer (rotated away) from an *unrelated* one.
454fn establishment_keys(device_kel: &[Event]) -> Vec<DevicePublicKey> {
455 device_kel
456 .iter()
457 .filter_map(|event| match event {
458 Event::Icp(e) => Some(&e.k),
459 Event::Dip(e) => Some(&e.k),
460 Event::Rot(e) => Some(&e.k),
461 Event::Drt(e) => Some(&e.k),
462 _ => None,
463 })
464 .flatten()
465 .filter_map(cesr_to_device_pk)
466 .collect()
467}
468
469/// Decode a CESR-encoded verkey into a curve-tagged device public key.
470fn cesr_to_device_pk(cesr: &CesrKey) -> Option<DevicePublicKey> {
471 let keri = KeriPublicKey::parse(cesr.as_str()).ok()?;
472 let curve = keri.curve();
473 let bytes = keri.into_bytes().to_vec();
474 DevicePublicKey::try_new(curve, &bytes).ok()
475}
476
477/// Verify a commit purely by KEL replay + delegation + in-process SSH-signature check.
478///
479/// Args:
480/// * `commit_bytes`: The raw git commit object (with the `gpgsig` SSH signature).
481/// * `device_kel`: The signer device's KEL events (a `dip`, or the root's `icp` when
482/// the root signs directly).
483/// * `root_kel`: The root identity's KEL events (the delegator).
484/// * `pinned_roots`: Trusted root `did:keri:` strings (from `.auths/roots`).
485/// * `provider`: Crypto provider for in-process signature verification.
486///
487/// Usage:
488/// ```ignore
489/// let verdict = verify_commit_against_kel(commit, &device_kel, &root_kel, &pinned, &provider).await;
490/// assert!(verdict.is_valid());
491/// ```
492pub async fn verify_commit_against_kel(
493 commit_bytes: &[u8],
494 device_kel: &[Event],
495 root_kel: &[Event],
496 pinned_roots: &[String],
497 provider: &dyn CryptoProvider,
498) -> CommitVerdict {
499 verify_commit_against_kel_witnessed(
500 commit_bytes,
501 device_kel,
502 root_kel,
503 pinned_roots,
504 provider,
505 &NoWitnessReceipts,
506 VerifierWitnessPolicy::Warn,
507 )
508 .await
509 .verdict
510}
511
512/// Verify a commit and gate the signer's root KEL on M-of-N witness receipts.
513///
514/// Like [`verify_commit_against_kel`] but resolves the root KEL's witness
515/// receipts through `receipt_lookup` and applies a verifier-side `policy`:
516/// under [`VerifierWitnessPolicy::Warn`] an under-quorum root is a non-fatal
517/// [`WitnessGateStatus::UnderQuorum`]; under
518/// [`VerifierWitnessPolicy::RequireWitnesses`] it is a fatal
519/// [`CommitVerdict::WitnessQuorumNotMet`]. A `bt=0` root verifies unchanged.
520///
521/// Args:
522/// * `commit_bytes`: The raw git commit object.
523/// * `device_kel`: The signer device's KEL events.
524/// * `root_kel`: The root (delegator) KEL events.
525/// * `pinned_roots`: Trusted root `did:keri:` strings.
526/// * `provider`: Crypto provider for signature verification.
527/// * `receipt_lookup`: Source of the root KEL's witness receipts.
528/// * `policy`: The verifier's witness policy (independent of the signer's).
529///
530/// Usage:
531/// ```ignore
532/// let wv = verify_commit_against_kel_witnessed(c, &dk, &rk, &pinned, &p, &lookup, policy).await;
533/// assert!(wv.verdict.is_valid());
534/// ```
535pub async fn verify_commit_against_kel_witnessed(
536 commit_bytes: &[u8],
537 device_kel: &[Event],
538 root_kel: &[Event],
539 pinned_roots: &[String],
540 provider: &dyn CryptoProvider,
541 receipt_lookup: &dyn WitnessReceiptLookup,
542 policy: VerifierWitnessPolicy,
543) -> WitnessedVerdict {
544 verify_commit_against_kel_witnessed_at(
545 commit_bytes,
546 device_kel,
547 root_kel,
548 pinned_roots,
549 provider,
550 receipt_lookup,
551 policy,
552 None,
553 )
554 .await
555}
556
557/// Verify a commit with the witness gate AND the delegator-anchored scope/expiry
558/// gate, evaluated against the injected signing time `now` (Unix epoch seconds).
559///
560/// Identical to [`verify_commit_against_kel_witnessed`] plus: when the signer is a
561/// delegate whose delegator anchored a scope seal, a commit exercising a capability
562/// outside that scope is rejected with [`CommitVerdict::OutsideAgentScope`], and a
563/// commit signed at/after the anchored expiry is rejected with
564/// [`CommitVerdict::AgentExpired`]. Scope is always read from the delegator's
565/// (`root_kel`'s) anchored seals, never agent-self-asserted — a delegate cannot
566/// widen its own grant.
567///
568/// Args:
569/// * `commit_bytes`: The raw git commit object.
570/// * `device_kel`: The signer's KEL (a delegate `dip`, or a root `icp`).
571/// * `root_kel`: The delegator's KEL (carries the scope seal).
572/// * `pinned_roots`: Trusted root `did:keri:` strings.
573/// * `provider`: Crypto provider for signature verification.
574/// * `receipt_lookup`: Source of the root KEL's witness receipts.
575/// * `policy`: The verifier's witness policy (independent of the signer's).
576/// * `now`: The signing time to check scope/expiry against (injected at the boundary).
577///
578/// Usage:
579/// ```ignore
580/// let wv = verify_commit_against_kel_witnessed_scoped(c, &dk, &rk, &pinned, &p, &lk, policy, now).await;
581/// ```
582#[allow(clippy::too_many_arguments)]
583pub async fn verify_commit_against_kel_witnessed_scoped(
584 commit_bytes: &[u8],
585 device_kel: &[Event],
586 root_kel: &[Event],
587 pinned_roots: &[String],
588 provider: &dyn CryptoProvider,
589 receipt_lookup: &dyn WitnessReceiptLookup,
590 policy: VerifierWitnessPolicy,
591 now: i64,
592) -> WitnessedVerdict {
593 verify_commit_against_kel_witnessed_at(
594 commit_bytes,
595 device_kel,
596 root_kel,
597 pinned_roots,
598 provider,
599 receipt_lookup,
600 policy,
601 Some(now),
602 )
603 .await
604}
605
606/// Shared body of the witnessed commit-verify path: replay + witness-gate the root
607/// KEL, then authorize the commit. `now` is `None` for the unscoped entrypoint and
608/// `Some(epoch_secs)` when the delegator-anchored scope/expiry gate must evaluate.
609#[allow(clippy::too_many_arguments)]
610async fn verify_commit_against_kel_witnessed_at(
611 commit_bytes: &[u8],
612 device_kel: &[Event],
613 root_kel: &[Event],
614 pinned_roots: &[String],
615 provider: &dyn CryptoProvider,
616 receipt_lookup: &dyn WitnessReceiptLookup,
617 policy: VerifierWitnessPolicy,
618 now: Option<i64>,
619) -> WitnessedVerdict {
620 // 1. Replay + witness-gate the root KEL (validates SAIDs incl. the
621 // self-addressing icp prefix, then checks M-of-N witness agreement).
622 // rt-002-allow: root_kel is authenticated at the ingestion boundary before it reaches here (CI --identity-bundle → validate_signed_kel in load_bundle_trust; local registry = trusted self-owned store), and this replay additionally enforces the M-of-N witness gate.
623 let replay = match TrustedKel::from_trusted_source(root_kel)
624 .replay_with_receipts(None, receipt_lookup)
625 {
626 Ok(r) => r,
627 Err(e) => {
628 return WitnessedVerdict {
629 verdict: CommitVerdict::RootKelInvalid(e.to_string()),
630 witness: WitnessGateStatus::NotRequired,
631 };
632 }
633 };
634 let root_state = replay.state().clone();
635 let root_did = format!("did:keri:{}", root_state.prefix);
636
637 let witness = match &replay {
638 WitnessedReplay::Accepted(s) => {
639 if s.backers.is_empty() {
640 WitnessGateStatus::NotRequired
641 } else {
642 WitnessGateStatus::Met
643 }
644 }
645 WitnessedReplay::Pending {
646 collected,
647 required,
648 state,
649 ..
650 } => {
651 let required = required
652 .simple_value()
653 .map(|v| v as usize)
654 .unwrap_or(state.backers.len());
655 let status = WitnessGateStatus::UnderQuorum {
656 collected: *collected,
657 required,
658 };
659 // The verifier's own policy decides fail-open vs fail-closed —
660 // never the signer's self-declared WitnessPolicy.
661 if matches!(policy, VerifierWitnessPolicy::RequireWitnesses) {
662 return WitnessedVerdict {
663 verdict: CommitVerdict::WitnessQuorumNotMet {
664 root_did,
665 collected: *collected,
666 required,
667 },
668 witness: status,
669 };
670 }
671 status
672 }
673 };
674
675 let verdict = authorize_commit(
676 commit_bytes,
677 device_kel,
678 root_kel,
679 pinned_roots,
680 provider,
681 root_state,
682 now,
683 )
684 .await;
685 WitnessedVerdict { verdict, witness }
686}
687
688/// Verify a commit and additionally enforce the agent's delegator-anchored
689/// scope/expiry against an injected signing time `now` (Unix epoch seconds).
690///
691/// Identical to [`verify_commit_against_kel`] plus: a delegated signer whose
692/// delegator anchored a scope seal is rejected when the commit exercises a capability
693/// outside that scope ([`CommitVerdict::OutsideAgentScope`]) or signs at/after the
694/// anchored expiry ([`CommitVerdict::AgentExpired`], checked against `now`).
695///
696/// Args:
697/// * `commit_bytes`: The signed commit.
698/// * `device_kel`: The signer's KEL.
699/// * `root_kel`: The delegator's KEL (carries the scope seal).
700/// * `pinned_roots`: Trusted root DIDs.
701/// * `provider`: Crypto provider for signature verification.
702/// * `now`: The signing time to check expiry against (injected at the boundary).
703///
704/// Usage:
705/// ```ignore
706/// let verdict = verify_commit_against_kel_scoped(commit, &device_kel, &root_kel, &pinned, &provider, now).await;
707/// ```
708pub async fn verify_commit_against_kel_scoped(
709 commit_bytes: &[u8],
710 device_kel: &[Event],
711 root_kel: &[Event],
712 pinned_roots: &[String],
713 provider: &dyn CryptoProvider,
714 now: i64,
715) -> CommitVerdict {
716 // rt-002-allow: root_kel is authenticated at the ingestion boundary (CI --identity-bundle → validate_signed_kel in load_bundle_trust; local registry = trusted self-owned store).
717 let root_state = match TrustedKel::from_trusted_source(root_kel).replay() {
718 Ok(state) => state,
719 Err(e) => return CommitVerdict::RootKelInvalid(e.to_string()),
720 };
721 authorize_commit(
722 commit_bytes,
723 device_kel,
724 root_kel,
725 pinned_roots,
726 provider,
727 root_state,
728 Some(now),
729 )
730 .await
731}
732
733/// Steps 2–6 of commit authorization, given an already replayed `root_state`:
734/// pinned-root + abandonment checks, device-KEL replay, delegation/revocation,
735/// duplicity warning, and the in-process SSH-signature binding.
736#[allow(clippy::too_many_arguments)]
737async fn authorize_commit(
738 commit_bytes: &[u8],
739 device_kel: &[Event],
740 root_kel: &[Event],
741 pinned_roots: &[String],
742 provider: &dyn CryptoProvider,
743 root_state: auths_keri::KeyState,
744 now: Option<i64>,
745) -> CommitVerdict {
746 let root_prefix = root_state.prefix.clone();
747 let root_did = format!("did:keri:{root_prefix}");
748
749 // 2. The root must be pinned (the trailer-claimed root may only SELECT a pinned root).
750 if !pinned_roots.contains(&root_did) {
751 return CommitVerdict::RootNotPinned(root_did);
752 }
753 if root_state.is_abandoned {
754 return CommitVerdict::RootAbandoned;
755 }
756
757 // 3. Replay the device KEL (a dip needs the delegator lookup against the root).
758 let lookup = KelSealIndex::from_events(root_kel);
759 // rt-002-allow: device_kel is authenticated at the ingestion boundary (CI --identity-bundle → validate_signed_kel in load_bundle_trust; local registry = trusted self-owned store); the dip's delegation is additionally bound to the already-replayed root via the root KelSealIndex.
760 let device_state =
761 match TrustedKel::from_trusted_source(device_kel).replay_with_lookup(Some(&lookup)) {
762 Ok(s) => s,
763 Err(e) => {
764 // A device dip the root never anchored fails replay here (the lookup
765 // can't resolve its delegation seal) — surface that distinctly from a
766 // structurally-broken device KEL.
767 if let Some(first @ Event::Dip(_)) = device_kel.first()
768 && validate_delegation(first, root_kel).is_err()
769 {
770 return CommitVerdict::DelegationSealNotFound;
771 }
772 return CommitVerdict::DeviceKelInvalid(e.to_string());
773 }
774 };
775 let device_prefix = device_state.prefix.clone();
776 let device_did = format!("did:keri:{device_prefix}");
777
778 // 4. Authorization: the pinned root signing directly, or a non-revoked, in-scope
779 // delegate. Replay already confirmed the dip is anchored by *a* delegator (via the
780 // lookup); this confirms that delegator is THIS root and the delegation is live.
781 if let Some(verdict) = reject_unauthorized_delegate(
782 commit_bytes,
783 root_kel,
784 &root_prefix,
785 &device_state,
786 &device_did,
787 &root_did,
788 now,
789 ) {
790 return verdict;
791 }
792
793 // 5. Non-fatal duplicity warning on the root KEL (trust-on-first-sight, fail-open).
794 let refs: Vec<KelEventRef> = root_kel
795 .iter()
796 .map(|e| KelEventRef {
797 prefix: root_prefix.as_str(),
798 seq: e.sequence().value() as u64,
799 said: e.said().as_str(),
800 })
801 .collect();
802 let duplicitous_root = !matches!(
803 detect_duplicity(&refs),
804 crate::duplicity::DuplicityReport::Clean
805 );
806
807 // 6. Binding + in-process SSH-signature verification against the device's CURRENT key.
808 let Some(current_cesr) = device_state.current_keys.first() else {
809 return CommitVerdict::DeviceKelInvalid("device KEL has no current key".to_string());
810 };
811 let Some(current_pk) = cesr_to_device_pk(current_cesr) else {
812 return CommitVerdict::DeviceKelInvalid("device current key is undecodable".to_string());
813 };
814
815 match verify_commit_signature(
816 commit_bytes,
817 std::slice::from_ref(¤t_pk),
818 provider,
819 None,
820 )
821 .await
822 {
823 Ok(_) => CommitVerdict::Valid {
824 signer_did: device_did,
825 root_did,
826 duplicitous_root,
827 as_of: device_state.sequence,
828 freshness: Freshness::Unknown,
829 },
830 Err(CommitVerificationError::UnsignedCommit) => CommitVerdict::Unsigned,
831 Err(CommitVerificationError::GpgNotSupported) => CommitVerdict::GpgUnsupported,
832 Err(CommitVerificationError::SignatureInvalid) => CommitVerdict::SshSignatureInvalid,
833 Err(CommitVerificationError::NamespaceMismatch { .. }) => {
834 CommitVerdict::SshSignatureInvalid
835 }
836 Err(CommitVerificationError::UnknownSigner) => {
837 classify_unknown_signer(commit_bytes, device_kel, ¤t_pk)
838 }
839 Err(_) => CommitVerdict::SshSignatureInvalid,
840 }
841}
842
843/// Step 4 of [`authorize_commit`]: reject a delegate that is not authorized by this
844/// root. Returns `Some(verdict)` to reject, `None` when the signer is the pinned root
845/// signing directly or a live, in-scope delegate.
846///
847/// Checks (in order): the delegation names THIS root; the delegate is not revoked
848/// (ordered by KEL position, KERI carries no wall-clock — signed-before stays valid,
849/// signed-at/after fails, unknown position is the conservative flat rejection); and
850/// the commit stays within the delegator-anchored scope (enforced whenever the
851/// delegator anchored a scope seal — never agent-self-asserted) and, when a signing
852/// time is injected, before any anchored expiry.
853fn reject_unauthorized_delegate(
854 commit_bytes: &[u8],
855 root_kel: &[Event],
856 root_prefix: &Prefix,
857 device_state: &auths_keri::KeyState,
858 device_did: &str,
859 root_did: &str,
860 now: Option<i64>,
861) -> Option<CommitVerdict> {
862 let device_prefix = device_state.prefix.clone();
863 let root_signs_directly = device_prefix == *root_prefix && device_state.delegator.is_none();
864 if root_signs_directly {
865 return None;
866 }
867
868 match &device_state.delegator {
869 Some(delegator) if *delegator == *root_prefix => {}
870 _ => {
871 return Some(CommitVerdict::NotDelegatedByClaimedRoot {
872 device_did: device_did.to_string(),
873 root_did: root_did.to_string(),
874 });
875 }
876 }
877
878 let revocation = revocation_position(root_kel, &device_prefix);
879 match classify_revocation(parse_anchor_seq(commit_bytes), revocation) {
880 RevocationOrdering::NotRevoked => {}
881 // RT-003: revocation is terminal for NEW signatures. The in-band
882 // `Auths-Anchor-Seq` is signer-chosen, so a "signed before revocation"
883 // claim is not a trustworthy ordering source — a revoked-but-unrotated
884 // key would simply claim position 0. Until an INDEPENDENT signal exists
885 // (witness-receipted KSN / transparency log / signed git-history
886 // reachability), a currently-revoked delegate fails closed regardless of
887 // the self-reported position. This over-rejects genuinely-prior commits
888 // in the stateless verifier — the accepted interim cost (open question 2).
889 // `SignedBefore` is still computed so the witness-ordered fix can later
890 // accept it when independently corroborated.
891 RevocationOrdering::SignedBefore | RevocationOrdering::RevokedUnknownPosition => {
892 return Some(CommitVerdict::DeviceRevoked);
893 }
894 RevocationOrdering::SignedAfter {
895 signed_at,
896 revoked_at,
897 } => {
898 return Some(CommitVerdict::SignedAfterRevocation {
899 signer_did: device_did.to_string(),
900 signed_at,
901 revoked_at,
902 });
903 }
904 }
905
906 if let Some(scope) = read_agent_scope_from_kel(root_kel, &device_prefix) {
907 // Expiry is time-dependent: only enforceable when a signing time is injected.
908 if let Some(now) = now
909 && let Some(expires_at) = scope.expires_at
910 && now >= expires_at
911 {
912 return Some(CommitVerdict::AgentExpired {
913 signer_did: device_did.to_string(),
914 expired_at: expires_at,
915 signed_at: now,
916 });
917 }
918 // Capability attenuation is time-independent: a delegate may never exceed the
919 // delegator-anchored scope, so it is enforced whether or not a time is given.
920 //
921 // Accepted risk: an empty delegator-anchored capability list means UNCONSTRAINED, not
922 // "deny all" — with nothing to attenuate against, every claimed capability passes. This is
923 // by design: the scope is advisory authorization, and a delegator that anchors a capless
924 // scope is choosing not to constrain the delegate. A capless grant is therefore as
925 // powerful as the delegator; constrain a delegate by anchoring a non-empty capability set.
926 if !scope.capabilities.is_empty() {
927 for claimed in parse_scope_claim(commit_bytes) {
928 if !scope.capabilities.iter().any(|c| c.as_str() == claimed) {
929 return Some(CommitVerdict::OutsideAgentScope {
930 signer_did: device_did.to_string(),
931 capability: claimed,
932 });
933 }
934 }
935 }
936 }
937
938 None
939}
940
941/// The SSH signer key isn't the current key — distinguish a *superseded* device key
942/// (rotated away) from an unrelated one for a clearer verdict.
943fn classify_unknown_signer(
944 commit_bytes: &[u8],
945 device_kel: &[Event],
946 current_pk: &DevicePublicKey,
947) -> CommitVerdict {
948 let Ok(content) = std::str::from_utf8(commit_bytes) else {
949 return CommitVerdict::SignerKeyMismatch;
950 };
951 let Ok(extracted) = extract_ssh_signature(content) else {
952 return CommitVerdict::SignerKeyMismatch;
953 };
954 let Ok(envelope) = parse_sshsig_pem(&extracted.signature_pem) else {
955 return CommitVerdict::SignerKeyMismatch;
956 };
957 if envelope.public_key != *current_pk
958 && establishment_keys(device_kel).contains(&envelope.public_key)
959 {
960 return CommitVerdict::SignedBySupersededKey;
961 }
962 CommitVerdict::SignerKeyMismatch
963}
964
965#[cfg(test)]
966#[allow(clippy::unwrap_used, clippy::expect_used)]
967mod tests {
968 use super::*;
969
970 #[test]
971 fn trailer_round_trips_signing_sequence() {
972 assert_eq!(anchor_seq_trailer(7), "Auths-Anchor-Seq: 7");
973 // Parses out of a realistic multi-line commit body.
974 let commit =
975 "fix: a thing\n\nbody line\n\nAuths-Id: did:keri:Eroot\nAuths-Anchor-Seq: 42\n";
976 assert_eq!(parse_anchor_seq(commit.as_bytes()), Some(42));
977 assert_eq!(parse_anchor_seq(b"no trailer here"), None);
978 }
979
980 #[test]
981 fn commit_before_revocation_still_valid() {
982 // Signed at KEL position 1, revoked at 2 → before → not rejected.
983 assert_eq!(
984 classify_revocation(Some(1), Some(2)),
985 RevocationOrdering::SignedBefore
986 );
987 }
988
989 #[test]
990 fn commit_after_revocation_rejected_by_position() {
991 // Signed at position 3, revoked at 2 → at/after → rejected with both positions.
992 assert_eq!(
993 classify_revocation(Some(3), Some(2)),
994 RevocationOrdering::SignedAfter {
995 signed_at: 3,
996 revoked_at: 2
997 }
998 );
999 // Signed exactly at the revocation position is also rejected.
1000 assert!(matches!(
1001 classify_revocation(Some(2), Some(2)),
1002 RevocationOrdering::SignedAfter { .. }
1003 ));
1004 }
1005
1006 #[test]
1007 fn revocation_ordering_is_kel_position_not_wallclock() {
1008 // Ordering depends only on KEL positions — no clock is consulted.
1009 // Not revoked → always valid regardless of any position.
1010 assert_eq!(
1011 classify_revocation(Some(99), None),
1012 RevocationOrdering::NotRevoked
1013 );
1014 // Revoked but the commit carries no position → conservative (cannot order).
1015 assert_eq!(
1016 classify_revocation(None, Some(5)),
1017 RevocationOrdering::RevokedUnknownPosition
1018 );
1019 // The same revocation position yields opposite verdicts purely by the
1020 // signing position — proving it is positional, not temporal.
1021 assert_eq!(
1022 classify_revocation(Some(4), Some(5)),
1023 RevocationOrdering::SignedBefore
1024 );
1025 assert!(matches!(
1026 classify_revocation(Some(6), Some(5)),
1027 RevocationOrdering::SignedAfter { .. }
1028 ));
1029 }
1030}