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, DelegatorKelLookup, Event, KeriPublicKey, Prefix, Said, Seal, SourceSeal,
14    WitnessedReplay, validate_delegation, validate_kel_with_lookup, validate_kel_with_receipts,
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::ssh_sig::parse_sshsig_pem;
22
23/// The outcome of KEL-native commit verification. Distinguishable so the CLI/UX can
24/// explain *why* a commit failed (never a generic `InvalidSignature`).
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum CommitVerdict {
27    /// Authorized: the signer is a non-revoked delegate of a pinned root (or the
28    /// pinned root itself) and the SSH signature matches its current key.
29    Valid {
30        /// The verified signer `did:keri:`.
31        signer_did: String,
32        /// The root `did:keri:` it chains to.
33        root_did: String,
34        /// True if the root KEL shows a fork (non-fatal warning — trust-on-first-sight).
35        duplicitous_root: bool,
36    },
37    /// The commit carries no SSH signature.
38    Unsigned,
39    /// The SSH signature did not validate (tampered commit, wrong namespace, or bad sig).
40    SshSignatureInvalid,
41    /// A PGP-signed commit (out of scope).
42    GpgUnsupported,
43    /// The signer's device KEL failed to replay/validate.
44    DeviceKelInvalid(String),
45    /// The root KEL failed to replay/validate.
46    RootKelInvalid(String),
47    /// The root identity is not in the pinned trusted-root set (`.auths/roots`).
48    RootNotPinned(String),
49    /// The root identity's KEL is abandoned.
50    RootAbandoned,
51    /// The device is not delegated by the claimed/pinned root.
52    NotDelegatedByClaimedRoot {
53        /// The device's `did:keri:`.
54        device_did: String,
55        /// The root we verified against.
56        root_did: String,
57    },
58    /// The root never anchored the device's delegated inception.
59    DelegationSealNotFound,
60    /// The root has revoked this device/agent's delegation and the commit carries
61    /// no in-band signing position, so it cannot be ordered against the revocation
62    /// (conservative flat rejection — preserves the no-position default).
63    DeviceRevoked,
64    /// The commit was signed **at or after** the delegator anchored the revocation
65    /// (its in-band `Auths-Anchor-Seq` is ≥ the revocation's KEL position). Distinct
66    /// from [`CommitVerdict::DeviceRevoked`]: a commit signed *before* the revocation
67    /// stays [`CommitVerdict::Valid`] — revocation is ordered by KEL position, never
68    /// wall-clock, so legitimate prior history is not retroactively invalidated.
69    SignedAfterRevocation {
70        /// The signer's `did:keri:`.
71        signer_did: String,
72        /// The signing position claimed in-band (`Auths-Anchor-Seq`).
73        signed_at: u128,
74        /// The KEL position at which the delegator anchored the revocation.
75        revoked_at: u128,
76    },
77    /// The agent signed exercising a capability outside its delegator-anchored
78    /// scope (the delegator never granted it). Scope is advisory authorization
79    /// anchored by the delegator (the ACDC upgrade is Epic F).
80    OutsideAgentScope {
81        /// The signer's `did:keri:`.
82        signer_did: String,
83        /// The capability the commit claimed that the scope does not grant.
84        capability: String,
85    },
86    /// The agent signed at/after its delegator-anchored expiry. Checked against the
87    /// signing time via an injected `now` (no wall-clock in the verifier).
88    AgentExpired {
89        /// The signer's `did:keri:`.
90        signer_did: String,
91        /// The expiry instant (Unix epoch seconds) the delegator anchored.
92        expired_at: i64,
93        /// The signing time the commit was checked against (injected `now`).
94        signed_at: i64,
95    },
96    /// The SSH signer key is not the device's current key (and not a known prior key).
97    SignerKeyMismatch,
98    /// The SSH signer key is a *superseded* device key (the device rotated since signing).
99    SignedBySupersededKey,
100    /// Under `--require-witnesses`, the signer's root KEL did not reach M-of-N
101    /// witness quorum for an establishment event (fail-closed).
102    WitnessQuorumNotMet {
103        /// The root `did:keri:` whose KEL is under-quorum.
104        root_did: String,
105        /// Distinct valid witness receipts collected.
106        collected: usize,
107        /// Receipts required by the in-force backer threshold.
108        required: usize,
109    },
110}
111
112/// Verifier-side witness policy — independent of the signer's own `WitnessPolicy`.
113///
114/// A verifier cannot trust the signer's self-declared policy (it lives in the
115/// signer's config), so it sets its own.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
117pub enum VerifierWitnessPolicy {
118    /// Under-quorum signer key-state is a non-fatal warning (preserves the
119    /// Stage-1 trust-on-first-sight caveat during rollout). The default.
120    #[default]
121    Warn,
122    /// Under-quorum signer key-state fails verification (fail-closed).
123    RequireWitnesses,
124}
125
126/// Witness-quorum status of a verified signer KEL, surfaced for CLI display (D.9).
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum WitnessGateStatus {
129    /// The signer KEL designates no witnesses (`bt=0`); none required.
130    NotRequired,
131    /// Witness quorum was met.
132    Met,
133    /// Quorum was not met but accepted anyway under [`VerifierWitnessPolicy::Warn`].
134    UnderQuorum {
135        /// Distinct valid receipts collected.
136        collected: usize,
137        /// Receipts required by the in-force backer threshold.
138        required: usize,
139    },
140}
141
142/// A commit verdict paired with the signer KEL's witness-quorum status.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct WitnessedVerdict {
145    /// The commit authorization verdict.
146    pub verdict: CommitVerdict,
147    /// Witness-quorum status of the signer's (root) KEL.
148    pub witness: WitnessGateStatus,
149}
150
151impl CommitVerdict {
152    /// Whether the commit is authorized (a `Valid` verdict, regardless of the
153    /// non-fatal duplicity warning).
154    pub fn is_valid(&self) -> bool {
155        matches!(self, CommitVerdict::Valid { .. })
156    }
157}
158
159/// `DelegatorKelLookup` over an in-memory root KEL slice — answers "did the root
160/// anchor a seal for this delegated event?" by scanning the root KEL's seals.
161struct RootKelLookup<'a> {
162    root_kel: &'a [Event],
163}
164
165impl DelegatorKelLookup for RootKelLookup<'_> {
166    fn find_seal(&self, _delegator_aid: &Prefix, seal_said: &Said) -> Option<SourceSeal> {
167        for event in self.root_kel {
168            for seal in event.anchors() {
169                if let Seal::KeyEvent { d, .. } = seal
170                    && d == seal_said
171                {
172                    return Some(SourceSeal {
173                        s: event.sequence(),
174                        d: event.said().clone(),
175                    });
176                }
177            }
178        }
179        None
180    }
181}
182
183/// The KEL position (root sequence) at which the delegator anchored a revocation
184/// (`Seal::Digest{d == device_prefix}`) for the device/agent, or `None` if not
185/// revoked. KERI carries no wall-clock, so revocation is ordered by this position.
186///
187/// Args:
188/// * `root_kel`: The delegator's KEL.
189/// * `device_prefix`: The delegated identifier's prefix.
190fn revocation_position(root_kel: &[Event], device_prefix: &Prefix) -> Option<u128> {
191    root_kel.iter().find_map(|event| {
192        let revokes = event
193            .anchors()
194            .iter()
195            .any(|seal| matches!(seal, Seal::Digest { d } if d.as_str() == device_prefix.as_str()));
196        revokes.then(|| event.sequence().value())
197    })
198}
199
200/// The CESR commit trailer key carrying the signer's in-band KEL position — the
201/// delegator-anchoring sequence in force when the commit was signed. Lets the
202/// verifier order a commit against a later revocation by KEL position.
203pub const ANCHOR_SEQ_TRAILER: &str = "Auths-Anchor-Seq";
204
205/// Format the signing-position commit trailer (`Auths-Anchor-Seq: <seq>`).
206///
207/// Args:
208/// * `seq`: The delegator-anchoring sequence in force at signing.
209///
210/// Usage:
211/// ```
212/// use auths_verifier::anchor_seq_trailer;
213/// assert_eq!(anchor_seq_trailer(7), "Auths-Anchor-Seq: 7");
214/// ```
215pub fn anchor_seq_trailer(seq: u128) -> String {
216    format!("{ANCHOR_SEQ_TRAILER}: {seq}")
217}
218
219/// Parse the signer's in-band KEL position from a commit's `Auths-Anchor-Seq`
220/// trailer, or `None` if absent/unparseable.
221///
222/// Args:
223/// * `commit_bytes`: The raw signed commit content.
224fn parse_anchor_seq(commit_bytes: &[u8]) -> Option<u128> {
225    let text = std::str::from_utf8(commit_bytes).ok()?;
226    text.lines().find_map(|line| {
227        let rest = line.trim().strip_prefix(ANCHOR_SEQ_TRAILER)?;
228        rest.trim_start()
229            .strip_prefix(':')?
230            .trim()
231            .parse::<u128>()
232            .ok()
233    })
234}
235
236/// The CESR commit trailer key carrying the capability the commit exercises, checked
237/// against the agent's delegator-anchored scope.
238pub const SCOPE_TRAILER: &str = "Auths-Scope";
239
240/// Format a scope-claim commit trailer (`Auths-Scope: <cap>[,<cap>…]`).
241///
242/// Args:
243/// * `capabilities`: The capabilities the commit exercises.
244///
245/// Usage:
246/// ```
247/// use auths_verifier::scope_trailer;
248/// assert_eq!(scope_trailer(&["sign_commit".into()]), "Auths-Scope: sign_commit");
249/// ```
250pub fn scope_trailer(capabilities: &[String]) -> String {
251    format!("{SCOPE_TRAILER}: {}", capabilities.join(","))
252}
253
254/// Parse the capabilities a commit claims from its `Auths-Scope` trailer.
255fn parse_scope_claim(commit_bytes: &[u8]) -> Vec<String> {
256    let Ok(text) = std::str::from_utf8(commit_bytes) else {
257        return Vec::new();
258    };
259    text.lines()
260        .find_map(|line| {
261            line.trim()
262                .strip_prefix(SCOPE_TRAILER)?
263                .trim_start()
264                .strip_prefix(':')
265                .map(|rest| {
266                    rest.trim()
267                        .split(',')
268                        .filter(|c| !c.is_empty())
269                        .map(|c| c.trim().to_string())
270                        .collect::<Vec<_>>()
271                })
272        })
273        .unwrap_or_default()
274}
275
276/// The agent's latest delegator-anchored scope in `root_kel`, or `None`.
277fn read_agent_scope_from_kel(
278    root_kel: &[Event],
279    agent_prefix: &Prefix,
280) -> Option<auths_keri::AgentScope> {
281    let mut found = None;
282    for event in root_kel {
283        for seal in event.anchors() {
284            if let Seal::Digest { d } = seal
285                && let Some((prefix, scope)) = auths_keri::decode_agent_scope(d.as_str())
286                && prefix == agent_prefix.as_str()
287            {
288                found = Some(scope);
289            }
290        }
291    }
292    found
293}
294
295/// How a commit's signing position orders against a revocation.
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297enum RevocationOrdering {
298    /// The delegation was never revoked.
299    NotRevoked,
300    /// The commit was signed strictly before the revocation's KEL position — valid.
301    SignedBefore,
302    /// The commit was signed at/after the revocation's KEL position — rejected.
303    SignedAfter {
304        /// The signing position claimed in-band.
305        signed_at: u128,
306        /// The revocation's KEL position.
307        revoked_at: u128,
308    },
309    /// Revoked, but the commit carries no in-band position — cannot be ordered.
310    RevokedUnknownPosition,
311}
312
313/// Order a commit's in-band signing position against the revocation position.
314///
315/// A commit signed *before* the revocation stays valid (legitimate prior history is
316/// not retroactively invalidated); one signed *at or after* it is rejected. Without
317/// an in-band position, the result is conservative ([`RevocationOrdering::RevokedUnknownPosition`]).
318fn classify_revocation(
319    signing_anchor: Option<u128>,
320    revocation: Option<u128>,
321) -> RevocationOrdering {
322    match (revocation, signing_anchor) {
323        (None, _) => RevocationOrdering::NotRevoked,
324        (Some(_), None) => RevocationOrdering::RevokedUnknownPosition,
325        (Some(rev), Some(sign)) if sign < rev => RevocationOrdering::SignedBefore,
326        (Some(rev), Some(sign)) => RevocationOrdering::SignedAfter {
327            signed_at: sign,
328            revoked_at: rev,
329        },
330    }
331}
332
333/// The establishment keys (`k[]`) across a device KEL, parsed to device pubkeys —
334/// used to tell a *superseded* signer (rotated away) from an *unrelated* one.
335fn establishment_keys(device_kel: &[Event]) -> Vec<DevicePublicKey> {
336    device_kel
337        .iter()
338        .filter_map(|event| match event {
339            Event::Icp(e) => Some(&e.k),
340            Event::Dip(e) => Some(&e.k),
341            Event::Rot(e) => Some(&e.k),
342            Event::Drt(e) => Some(&e.k),
343            _ => None,
344        })
345        .flatten()
346        .filter_map(cesr_to_device_pk)
347        .collect()
348}
349
350/// Decode a CESR-encoded verkey into a curve-tagged device public key.
351fn cesr_to_device_pk(cesr: &CesrKey) -> Option<DevicePublicKey> {
352    let keri = KeriPublicKey::parse(cesr.as_str()).ok()?;
353    let curve = keri.curve();
354    let bytes = keri.into_bytes().to_vec();
355    DevicePublicKey::try_new(curve, &bytes).ok()
356}
357
358/// Verify a commit purely by KEL replay + delegation + in-process SSH-signature check.
359///
360/// Args:
361/// * `commit_bytes`: The raw git commit object (with the `gpgsig` SSH signature).
362/// * `device_kel`: The signer device's KEL events (a `dip`, or the root's `icp` when
363///   the root signs directly).
364/// * `root_kel`: The root identity's KEL events (the delegator).
365/// * `pinned_roots`: Trusted root `did:keri:` strings (from `.auths/roots`).
366/// * `provider`: Crypto provider for in-process signature verification.
367///
368/// Usage:
369/// ```ignore
370/// let verdict = verify_commit_against_kel(commit, &device_kel, &root_kel, &pinned, &provider).await;
371/// assert!(verdict.is_valid());
372/// ```
373pub async fn verify_commit_against_kel(
374    commit_bytes: &[u8],
375    device_kel: &[Event],
376    root_kel: &[Event],
377    pinned_roots: &[String],
378    provider: &dyn CryptoProvider,
379) -> CommitVerdict {
380    verify_commit_against_kel_witnessed(
381        commit_bytes,
382        device_kel,
383        root_kel,
384        pinned_roots,
385        provider,
386        &NoWitnessReceipts,
387        VerifierWitnessPolicy::Warn,
388    )
389    .await
390    .verdict
391}
392
393/// Verify a commit and gate the signer's root KEL on M-of-N witness receipts.
394///
395/// Like [`verify_commit_against_kel`] but resolves the root KEL's witness
396/// receipts through `receipt_lookup` and applies a verifier-side `policy`:
397/// under [`VerifierWitnessPolicy::Warn`] an under-quorum root is a non-fatal
398/// [`WitnessGateStatus::UnderQuorum`]; under
399/// [`VerifierWitnessPolicy::RequireWitnesses`] it is a fatal
400/// [`CommitVerdict::WitnessQuorumNotMet`]. A `bt=0` root verifies unchanged.
401///
402/// Args:
403/// * `commit_bytes`: The raw git commit object.
404/// * `device_kel`: The signer device's KEL events.
405/// * `root_kel`: The root (delegator) KEL events.
406/// * `pinned_roots`: Trusted root `did:keri:` strings.
407/// * `provider`: Crypto provider for signature verification.
408/// * `receipt_lookup`: Source of the root KEL's witness receipts.
409/// * `policy`: The verifier's witness policy (independent of the signer's).
410///
411/// Usage:
412/// ```ignore
413/// let wv = verify_commit_against_kel_witnessed(c, &dk, &rk, &pinned, &p, &lookup, policy).await;
414/// assert!(wv.verdict.is_valid());
415/// ```
416pub async fn verify_commit_against_kel_witnessed(
417    commit_bytes: &[u8],
418    device_kel: &[Event],
419    root_kel: &[Event],
420    pinned_roots: &[String],
421    provider: &dyn CryptoProvider,
422    receipt_lookup: &dyn WitnessReceiptLookup,
423    policy: VerifierWitnessPolicy,
424) -> WitnessedVerdict {
425    // 1. Replay + witness-gate the root KEL (validates SAIDs incl. the
426    //    self-addressing icp prefix, then checks M-of-N witness agreement).
427    let replay = match validate_kel_with_receipts(root_kel, None, receipt_lookup) {
428        Ok(r) => r,
429        Err(e) => {
430            return WitnessedVerdict {
431                verdict: CommitVerdict::RootKelInvalid(e.to_string()),
432                witness: WitnessGateStatus::NotRequired,
433            };
434        }
435    };
436    let root_state = replay.state().clone();
437    let root_did = format!("did:keri:{}", root_state.prefix);
438
439    let witness = match &replay {
440        WitnessedReplay::Accepted(s) => {
441            if s.backers.is_empty() {
442                WitnessGateStatus::NotRequired
443            } else {
444                WitnessGateStatus::Met
445            }
446        }
447        WitnessedReplay::Pending {
448            collected,
449            required,
450            state,
451            ..
452        } => {
453            let required = required
454                .simple_value()
455                .map(|v| v as usize)
456                .unwrap_or(state.backers.len());
457            let status = WitnessGateStatus::UnderQuorum {
458                collected: *collected,
459                required,
460            };
461            // The verifier's own policy decides fail-open vs fail-closed —
462            // never the signer's self-declared WitnessPolicy.
463            if matches!(policy, VerifierWitnessPolicy::RequireWitnesses) {
464                return WitnessedVerdict {
465                    verdict: CommitVerdict::WitnessQuorumNotMet {
466                        root_did,
467                        collected: *collected,
468                        required,
469                    },
470                    witness: status,
471                };
472            }
473            status
474        }
475    };
476
477    let verdict = authorize_commit(
478        commit_bytes,
479        device_kel,
480        root_kel,
481        pinned_roots,
482        provider,
483        root_state,
484        None,
485    )
486    .await;
487    WitnessedVerdict { verdict, witness }
488}
489
490/// Verify a commit and additionally enforce the agent's delegator-anchored
491/// scope/expiry against an injected signing time `now` (Unix epoch seconds).
492///
493/// Identical to [`verify_commit_against_kel`] plus: a delegated signer whose
494/// delegator anchored a scope seal is rejected when the commit exercises a capability
495/// outside that scope ([`CommitVerdict::OutsideAgentScope`]) or signs at/after the
496/// anchored expiry ([`CommitVerdict::AgentExpired`], checked against `now`).
497///
498/// Args:
499/// * `commit_bytes`: The signed commit.
500/// * `device_kel`: The signer's KEL.
501/// * `root_kel`: The delegator's KEL (carries the scope seal).
502/// * `pinned_roots`: Trusted root DIDs.
503/// * `provider`: Crypto provider for signature verification.
504/// * `now`: The signing time to check expiry against (injected at the boundary).
505///
506/// Usage:
507/// ```ignore
508/// let verdict = verify_commit_against_kel_scoped(commit, &device_kel, &root_kel, &pinned, &provider, now).await;
509/// ```
510pub async fn verify_commit_against_kel_scoped(
511    commit_bytes: &[u8],
512    device_kel: &[Event],
513    root_kel: &[Event],
514    pinned_roots: &[String],
515    provider: &dyn CryptoProvider,
516    now: i64,
517) -> CommitVerdict {
518    let root_state = match auths_keri::validate_kel(root_kel) {
519        Ok(state) => state,
520        Err(e) => return CommitVerdict::RootKelInvalid(e.to_string()),
521    };
522    authorize_commit(
523        commit_bytes,
524        device_kel,
525        root_kel,
526        pinned_roots,
527        provider,
528        root_state,
529        Some(now),
530    )
531    .await
532}
533
534/// Steps 2–6 of commit authorization, given an already replayed `root_state`:
535/// pinned-root + abandonment checks, device-KEL replay, delegation/revocation,
536/// duplicity warning, and the in-process SSH-signature binding.
537#[allow(clippy::too_many_arguments)]
538async fn authorize_commit(
539    commit_bytes: &[u8],
540    device_kel: &[Event],
541    root_kel: &[Event],
542    pinned_roots: &[String],
543    provider: &dyn CryptoProvider,
544    root_state: auths_keri::KeyState,
545    now: Option<i64>,
546) -> CommitVerdict {
547    let root_prefix = root_state.prefix.clone();
548    let root_did = format!("did:keri:{root_prefix}");
549
550    // 2. The root must be pinned (the trailer-claimed root may only SELECT a pinned root).
551    if !pinned_roots.contains(&root_did) {
552        return CommitVerdict::RootNotPinned(root_did);
553    }
554    if root_state.is_abandoned {
555        return CommitVerdict::RootAbandoned;
556    }
557
558    // 3. Replay the device KEL (a dip needs the delegator lookup against the root).
559    let lookup = RootKelLookup { root_kel };
560    let device_state = match validate_kel_with_lookup(device_kel, Some(&lookup)) {
561        Ok(s) => s,
562        Err(e) => {
563            // A device dip the root never anchored fails replay here (the lookup
564            // can't resolve its delegation seal) — surface that distinctly from a
565            // structurally-broken device KEL.
566            if let Some(first @ Event::Dip(_)) = device_kel.first()
567                && validate_delegation(first, root_kel).is_err()
568            {
569                return CommitVerdict::DelegationSealNotFound;
570            }
571            return CommitVerdict::DeviceKelInvalid(e.to_string());
572        }
573    };
574    let device_prefix = device_state.prefix.clone();
575    let device_did = format!("did:keri:{device_prefix}");
576
577    // 4. Authorization: the pinned root signing directly, or a non-revoked, in-scope
578    // delegate. Replay already confirmed the dip is anchored by *a* delegator (via the
579    // lookup); this confirms that delegator is THIS root and the delegation is live.
580    if let Some(verdict) = reject_unauthorized_delegate(
581        commit_bytes,
582        root_kel,
583        &root_prefix,
584        &device_state,
585        &device_did,
586        &root_did,
587        now,
588    ) {
589        return verdict;
590    }
591
592    // 5. Non-fatal duplicity warning on the root KEL (trust-on-first-sight, fail-open).
593    let refs: Vec<KelEventRef> = root_kel
594        .iter()
595        .map(|e| KelEventRef {
596            prefix: root_prefix.as_str(),
597            seq: e.sequence().value() as u64,
598            said: e.said().as_str(),
599        })
600        .collect();
601    let duplicitous_root = !matches!(
602        detect_duplicity(&refs),
603        crate::duplicity::DuplicityReport::Clean
604    );
605
606    // 6. Binding + in-process SSH-signature verification against the device's CURRENT key.
607    let Some(current_cesr) = device_state.current_keys.first() else {
608        return CommitVerdict::DeviceKelInvalid("device KEL has no current key".to_string());
609    };
610    let Some(current_pk) = cesr_to_device_pk(current_cesr) else {
611        return CommitVerdict::DeviceKelInvalid("device current key is undecodable".to_string());
612    };
613
614    match verify_commit_signature(
615        commit_bytes,
616        std::slice::from_ref(&current_pk),
617        provider,
618        None,
619    )
620    .await
621    {
622        Ok(_) => CommitVerdict::Valid {
623            signer_did: device_did,
624            root_did,
625            duplicitous_root,
626        },
627        Err(CommitVerificationError::UnsignedCommit) => CommitVerdict::Unsigned,
628        Err(CommitVerificationError::GpgNotSupported) => CommitVerdict::GpgUnsupported,
629        Err(CommitVerificationError::SignatureInvalid) => CommitVerdict::SshSignatureInvalid,
630        Err(CommitVerificationError::NamespaceMismatch { .. }) => {
631            CommitVerdict::SshSignatureInvalid
632        }
633        Err(CommitVerificationError::UnknownSigner) => {
634            classify_unknown_signer(commit_bytes, device_kel, &current_pk)
635        }
636        Err(_) => CommitVerdict::SshSignatureInvalid,
637    }
638}
639
640/// Step 4 of [`authorize_commit`]: reject a delegate that is not authorized by this
641/// root. Returns `Some(verdict)` to reject, `None` when the signer is the pinned root
642/// signing directly or a live, in-scope delegate.
643///
644/// Checks (in order): the delegation names THIS root; the delegate is not revoked
645/// (ordered by KEL position, KERI carries no wall-clock — signed-before stays valid,
646/// signed-at/after fails, unknown position is the conservative flat rejection); and
647/// the commit stays within the delegator-anchored scope and before any anchored
648/// expiry (only enforced when a signing time is injected and the delegator anchored a
649/// scope seal — never agent-self-asserted).
650fn reject_unauthorized_delegate(
651    commit_bytes: &[u8],
652    root_kel: &[Event],
653    root_prefix: &Prefix,
654    device_state: &auths_keri::KeyState,
655    device_did: &str,
656    root_did: &str,
657    now: Option<i64>,
658) -> Option<CommitVerdict> {
659    let device_prefix = device_state.prefix.clone();
660    let root_signs_directly = device_prefix == *root_prefix && device_state.delegator.is_none();
661    if root_signs_directly {
662        return None;
663    }
664
665    match &device_state.delegator {
666        Some(delegator) if *delegator == *root_prefix => {}
667        _ => {
668            return Some(CommitVerdict::NotDelegatedByClaimedRoot {
669                device_did: device_did.to_string(),
670                root_did: root_did.to_string(),
671            });
672        }
673    }
674
675    let revocation = revocation_position(root_kel, &device_prefix);
676    match classify_revocation(parse_anchor_seq(commit_bytes), revocation) {
677        RevocationOrdering::NotRevoked | RevocationOrdering::SignedBefore => {}
678        RevocationOrdering::RevokedUnknownPosition => return Some(CommitVerdict::DeviceRevoked),
679        RevocationOrdering::SignedAfter {
680            signed_at,
681            revoked_at,
682        } => {
683            return Some(CommitVerdict::SignedAfterRevocation {
684                signer_did: device_did.to_string(),
685                signed_at,
686                revoked_at,
687            });
688        }
689    }
690
691    if let Some(now) = now
692        && let Some(scope) = read_agent_scope_from_kel(root_kel, &device_prefix)
693    {
694        if let Some(expires_at) = scope.expires_at
695            && now >= expires_at
696        {
697            return Some(CommitVerdict::AgentExpired {
698                signer_did: device_did.to_string(),
699                expired_at: expires_at,
700                signed_at: now,
701            });
702        }
703        if !scope.capabilities.is_empty() {
704            for claimed in parse_scope_claim(commit_bytes) {
705                if !scope.capabilities.iter().any(|c| c.as_str() == claimed) {
706                    return Some(CommitVerdict::OutsideAgentScope {
707                        signer_did: device_did.to_string(),
708                        capability: claimed,
709                    });
710                }
711            }
712        }
713    }
714
715    None
716}
717
718/// The SSH signer key isn't the current key — distinguish a *superseded* device key
719/// (rotated away) from an unrelated one for a clearer verdict.
720fn classify_unknown_signer(
721    commit_bytes: &[u8],
722    device_kel: &[Event],
723    current_pk: &DevicePublicKey,
724) -> CommitVerdict {
725    let Ok(content) = std::str::from_utf8(commit_bytes) else {
726        return CommitVerdict::SignerKeyMismatch;
727    };
728    let Ok(extracted) = extract_ssh_signature(content) else {
729        return CommitVerdict::SignerKeyMismatch;
730    };
731    let Ok(envelope) = parse_sshsig_pem(&extracted.signature_pem) else {
732        return CommitVerdict::SignerKeyMismatch;
733    };
734    if envelope.public_key != *current_pk
735        && establishment_keys(device_kel).contains(&envelope.public_key)
736    {
737        return CommitVerdict::SignedBySupersededKey;
738    }
739    CommitVerdict::SignerKeyMismatch
740}
741
742#[cfg(test)]
743#[allow(clippy::unwrap_used, clippy::expect_used)]
744mod tests {
745    use super::*;
746
747    #[test]
748    fn trailer_round_trips_signing_sequence() {
749        assert_eq!(anchor_seq_trailer(7), "Auths-Anchor-Seq: 7");
750        // Parses out of a realistic multi-line commit body.
751        let commit =
752            "fix: a thing\n\nbody line\n\nAuths-Id: did:keri:Eroot\nAuths-Anchor-Seq: 42\n";
753        assert_eq!(parse_anchor_seq(commit.as_bytes()), Some(42));
754        assert_eq!(parse_anchor_seq(b"no trailer here"), None);
755    }
756
757    #[test]
758    fn commit_before_revocation_still_valid() {
759        // Signed at KEL position 1, revoked at 2 → before → not rejected.
760        assert_eq!(
761            classify_revocation(Some(1), Some(2)),
762            RevocationOrdering::SignedBefore
763        );
764    }
765
766    #[test]
767    fn commit_after_revocation_rejected_by_position() {
768        // Signed at position 3, revoked at 2 → at/after → rejected with both positions.
769        assert_eq!(
770            classify_revocation(Some(3), Some(2)),
771            RevocationOrdering::SignedAfter {
772                signed_at: 3,
773                revoked_at: 2
774            }
775        );
776        // Signed exactly at the revocation position is also rejected.
777        assert!(matches!(
778            classify_revocation(Some(2), Some(2)),
779            RevocationOrdering::SignedAfter { .. }
780        ));
781    }
782
783    #[test]
784    fn revocation_ordering_is_kel_position_not_wallclock() {
785        // Ordering depends only on KEL positions — no clock is consulted.
786        // Not revoked → always valid regardless of any position.
787        assert_eq!(
788            classify_revocation(Some(99), None),
789            RevocationOrdering::NotRevoked
790        );
791        // Revoked but the commit carries no position → conservative (cannot order).
792        assert_eq!(
793            classify_revocation(None, Some(5)),
794            RevocationOrdering::RevokedUnknownPosition
795        );
796        // The same revocation position yields opposite verdicts purely by the
797        // signing position — proving it is positional, not temporal.
798        assert_eq!(
799            classify_revocation(Some(4), Some(5)),
800            RevocationOrdering::SignedBefore
801        );
802        assert!(matches!(
803            classify_revocation(Some(6), Some(5)),
804            RevocationOrdering::SignedAfter { .. }
805        ));
806    }
807}