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(¤t_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, ¤t_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}