Skip to main content

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(&current_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, &current_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}