auths_sdk/workflows/commit_trust.rs
1//! KEL-native commit-trust resolution — the single path that decides whether a
2//! git commit is authorized by a pinned trusted root.
3//!
4//! Read the in-band `Auths-Id` / `Auths-Device` trailers, replay the device + root
5//! KELs from the registry, and check the signature against the pinned `.auths/roots`
6//! set. This is the successor to the `.auths/allowed_signers` SSH allowlist: trust is
7//! rooted in the replayed KEL, never an out-of-band list of keys. The verdict logic
8//! itself lives in `auths_verifier::verify_commit_against_kel`; this workflow owns the
9//! orchestration (trailer parse → KEL resolution → verdict).
10
11use chrono::{DateTime, Utc};
12
13/// Extract `(root_did, device_did)` from a commit's `Auths-Id` / `Auths-Device`
14/// trailers. One definition, in the verifier — the same parse the WASM/CLI
15/// stateless paths use, re-exported here for workflow callers.
16pub use auths_verifier::commit_signer_trailers;
17
18// `verify_commit_local` resolves KELs through `KelResolverChain`, which is only
19// compiled with the git registry backend; gate it and its imports accordingly so
20// `auths-sdk` still builds without `backend-git` (the pure helpers below do not).
21#[cfg(feature = "backend-git")]
22use crate::keri::KelResolverChain;
23#[cfg(feature = "backend-git")]
24use crate::ports::RegistryBackend;
25#[cfg(feature = "backend-git")]
26use auths_crypto::CryptoProvider;
27#[cfg(feature = "backend-git")]
28use auths_verifier::freshness::FreshnessPolicy;
29#[cfg(feature = "backend-git")]
30use auths_verifier::{CommitVerdict, verify_commit_against_kel};
31
32/// Failure resolving commit trust before a verdict can be reached.
33#[derive(Debug, thiserror::Error)]
34pub enum CommitTrustError {
35 /// The commit carries no `Auths-Id` / `Auths-Device` trailer pair.
36 #[error(
37 "commit carries no Auths-Id/Auths-Device trailer — it was not signed by `auths` \
38 (or predates KEL-native signing)"
39 )]
40 MissingTrailers,
41
42 /// A signer's KEL could not be resolved from the registry.
43 #[error("{role} KEL for {did} could not be resolved: {reason}")]
44 KelUnresolved {
45 /// Which KEL failed to resolve — `"device"` or `"root"`.
46 role: &'static str,
47 /// The `did:keri:` / `did:key:` whose KEL could not be resolved.
48 did: String,
49 /// The underlying resolver error, rendered for display.
50 reason: String,
51 },
52
53 /// Loading or evaluating the root's org policy failed.
54 #[error("org policy evaluation failed: {0}")]
55 Policy(#[from] crate::domains::org::error::OrgError),
56}
57
58/// The verifier's own root identity DID, implicitly trusted for its own
59/// verifications.
60///
61/// Self-trust is the floor of the trust model: a verifier always trusts keys it
62/// itself controls, so the signer can verify their own commits and artifacts with
63/// zero pinning ceremony. Returns `None` when no local identity exists (behavior
64/// is then unchanged — only explicit pins apply). The returned DID resolves
65/// through the same KEL replay as any pinned root; this adds a root, never a
66/// shortcut around verification.
67///
68/// Args:
69/// * `ctx`: Auths context (identity storage).
70///
71/// Usage:
72/// ```ignore
73/// if let Some(own_root) = local_self_root(&ctx) {
74/// pinned_roots.push(own_root);
75/// }
76/// ```
77pub fn local_self_root(ctx: &crate::context::AuthsContext) -> Option<String> {
78 ctx.identity_storage
79 .load_identity()
80 .ok()
81 .map(|managed| managed.controller_did.into_inner())
82}
83
84/// Verify a commit against the locally-replayed KEL and the pinned trusted roots.
85///
86/// Local-only (no network, no witness gate): resolves the device and root KELs from
87/// `registry`, then replays them to decide whether the commit's signer is a device
88/// delegated under a root pinned in `pinned_roots`. This is the trust primitive the
89/// artifact-provenance path uses for the human-signed commit an ephemeral attestation
90/// is bound to. Witnessed verification (the `auths verify` command) layers
91/// witness receipts on top of the same `auths_verifier` KEL primitive.
92///
93/// Args:
94/// * `registry`: The registry backend holding identity KELs.
95/// * `pinned_roots`: Trusted root `did:keri:` strings (from `.auths/roots`).
96/// * `raw_commit`: The raw git commit object bytes (with the `gpgsig` signature).
97/// * `provider`: Crypto provider for in-process signature verification.
98///
99/// Usage:
100/// ```ignore
101/// let verdict = verify_commit_local(®istry, &roots, commit_bytes, &provider).await?;
102/// assert!(verdict.is_valid());
103/// ```
104#[cfg(feature = "backend-git")]
105pub async fn verify_commit_local(
106 registry: &dyn RegistryBackend,
107 pinned_roots: &[String],
108 raw_commit: &[u8],
109 provider: &dyn CryptoProvider,
110) -> Result<CommitVerdict, CommitTrustError> {
111 let commit_str = String::from_utf8_lossy(raw_commit);
112 let (root_did, device_did) =
113 commit_signer_trailers(&commit_str).ok_or(CommitTrustError::MissingTrailers)?;
114
115 let chain = KelResolverChain::local(registry);
116 let device_kel =
117 chain
118 .resolve_kel(&device_did)
119 .map_err(|e| CommitTrustError::KelUnresolved {
120 role: "device",
121 did: device_did.clone(),
122 reason: e.to_string(),
123 })?;
124 let root_kel = chain
125 .resolve_kel(&root_did)
126 .map_err(|e| CommitTrustError::KelUnresolved {
127 role: "root",
128 did: root_did.clone(),
129 reason: e.to_string(),
130 })?;
131
132 Ok(verify_commit_against_kel(raw_commit, &device_kel, &root_kel, pinned_roots, provider).await)
133}
134
135/// The org-policy outcome layered on a cryptographically-valid commit verdict.
136///
137/// Policy is evaluated **after** the cryptographic verdict (fail-closed ordering: an
138/// unverified commit never reaches policy). It is a sum, never a bool.
139#[derive(Debug, Clone)]
140pub enum PolicyOutcome {
141 /// The cryptographic verdict was not `Valid`; policy was not evaluated (the commit
142 /// is already rejected by the verdict).
143 CryptoFailed,
144 /// The commit's root anchored no org policy — legacy allow, recorded (not silently
145 /// skipped). Pre-policy behavior is preserved for roots without a policy.
146 NoPolicy,
147 /// The org policy was evaluated; carries the typed [`auths_id::policy::Decision`].
148 Evaluated(auths_id::policy::Decision),
149}
150
151/// A commit's cryptographic verdict plus the org-policy decision layered on it.
152#[cfg(feature = "backend-git")]
153#[derive(Debug, Clone)]
154pub struct CommitDecision {
155 /// The cryptographic commit verdict (signature + KEL delegation + revocation).
156 pub verdict: CommitVerdict,
157 /// The org-policy outcome (only meaningful when `verdict.is_valid()`).
158 pub policy: PolicyOutcome,
159}
160
161#[cfg(feature = "backend-git")]
162impl CommitDecision {
163 /// True iff the commit is cryptographically valid **and** org policy authorizes it
164 /// (or the root anchored no policy). Fail-closed: any policy deny/indeterminate, a
165 /// non-`Valid` verdict, **or a duplicitous (forked) root KEL** is unauthorized.
166 pub fn is_authorized(&self) -> bool {
167 // Fail-closed on a duplicitous root KEL is enforced by `verdict.is_trusted` below
168 // (the shared trust primitive the CLI also consumes), not re-derived here.
169 if !self.verdict.is_trusted(&FreshnessPolicy::default()) {
170 return false;
171 }
172 match &self.policy {
173 PolicyOutcome::CryptoFailed => false,
174 PolicyOutcome::NoPolicy => true,
175 PolicyOutcome::Evaluated(decision) => decision.is_allowed(),
176 }
177 }
178}
179
180/// Map a delegated identifier's role marker to a policy signer type: an agent is an
181/// `Agent`; an unmarked device is the human's workstation (`Human`).
182fn signer_type_of(
183 ctx: &crate::context::AuthsContext,
184 root_prefix: &auths_id::keri::types::Prefix,
185 signer_prefix: &auths_id::keri::types::Prefix,
186) -> Result<auths_id::policy::SignerType, CommitTrustError> {
187 use auths_id::keri::delegation::{DelegatedRole, list_delegated_devices};
188 let delegated = list_delegated_devices(ctx.registry.as_ref(), root_prefix).map_err(|e| {
189 CommitTrustError::Policy(crate::domains::org::error::OrgError::Delegation(e))
190 })?;
191 let is_agent = delegated.iter().any(|d| {
192 d.device_prefix.as_str() == signer_prefix.as_str() && d.role == DelegatedRole::Agent
193 });
194 Ok(if is_agent {
195 auths_id::policy::SignerType::Agent
196 } else {
197 auths_id::policy::SignerType::Human
198 })
199}
200
201/// Build the policy evaluation context for a verified commit signer.
202///
203/// Caps/role come from the signer's delegator-anchored grant; `revoked = false`
204/// because a `Valid` verdict already certified the signer's authority was live at the
205/// signing position (positional revocation yields a non-`Valid` verdict). `signer_type`
206/// is read from the delegation role marker.
207fn commit_policy_context(
208 ctx: &crate::context::AuthsContext,
209 root_prefix: &auths_id::keri::types::Prefix,
210 signer_prefix: &auths_id::keri::types::Prefix,
211 now: DateTime<Utc>,
212) -> Result<auths_id::policy::EvalContext, CommitTrustError> {
213 use auths_id::policy::context_from_delegated_member;
214 use auths_verifier::core::Role;
215
216 let root_did = format!("did:keri:{}", root_prefix.as_str());
217 let signer_did = format!("did:keri:{}", signer_prefix.as_str());
218
219 let authority =
220 crate::domains::org::delegation::resolve_member_authority(ctx, root_prefix, signer_prefix)?;
221 let (role, caps, expires) = match &authority {
222 Some(a) => (
223 a.role.as_ref().map(Role::as_str),
224 a.capabilities.clone(),
225 a.expires_at,
226 ),
227 None => (None, Vec::new(), None),
228 };
229 let expires_dt = expires.and_then(|s| DateTime::from_timestamp(s, 0));
230
231 let mut eval_ctx =
232 context_from_delegated_member(&root_did, &signer_did, false, role, &caps, expires_dt, now)
233 .map_err(|e| {
234 CommitTrustError::Policy(crate::domains::org::error::OrgError::InvalidDid(
235 e.to_string(),
236 ))
237 })?;
238 eval_ctx = eval_ctx.signer_type(signer_type_of(ctx, root_prefix, signer_prefix)?);
239 Ok(eval_ctx)
240}
241
242/// Evaluate the commit signer against the root's org policy (if any).
243///
244/// Loads the policy anchored by `root_did` and evaluates a grant-based context for
245/// `signer_did`. A root with no anchored policy yields [`PolicyOutcome::NoPolicy`]
246/// (legacy allow, recorded). Pure over the registry + injected `now`.
247///
248/// Args:
249/// * `ctx`: Auths context (registry).
250/// * `root_did`: The commit's `Auths-Id` (the policy authority).
251/// * `signer_did`: The commit's `Auths-Device` (the signer being gated).
252/// * `now`: Current time, injected at the boundary.
253pub fn evaluate_commit_policy(
254 ctx: &crate::context::AuthsContext,
255 root_did: &str,
256 signer_did: &str,
257 now: DateTime<Utc>,
258) -> Result<PolicyOutcome, CommitTrustError> {
259 use auths_id::keri::types::Prefix;
260 let root_prefix = Prefix::new_unchecked(
261 root_did
262 .strip_prefix("did:keri:")
263 .unwrap_or(root_did)
264 .to_string(),
265 );
266 let signer_prefix = Prefix::new_unchecked(
267 signer_did
268 .strip_prefix("did:keri:")
269 .unwrap_or(signer_did)
270 .to_string(),
271 );
272
273 let Some(policy) = crate::domains::org::policy::load_org_policy(ctx, &root_prefix)? else {
274 return Ok(PolicyOutcome::NoPolicy);
275 };
276 let eval_ctx = commit_policy_context(ctx, &root_prefix, &signer_prefix, now)?;
277 let decision = crate::domains::org::policy::evaluate_with_org_policy(&policy, &eval_ctx);
278 // A5: record every enforcement decision (allow + deny) for the audit trail. The
279 // gate stays pure; emission happens here at the caller.
280 crate::audit::emit_policy_decision(ctx, "commit", signer_did, &decision);
281 Ok(PolicyOutcome::Evaluated(decision))
282}
283
284/// Verify a commit cryptographically **and** against the root's org policy.
285///
286/// Runs [`verify_commit_local`] then, only on a `Valid` verdict, layers the root's org
287/// policy ([`evaluate_commit_policy`]). The result is a typed [`CommitDecision`]: the
288/// crypto verdict + the policy outcome. Fail-closed throughout — crypto gates policy,
289/// and policy is a sum, never a bool.
290///
291/// Args:
292/// * `ctx`: Auths context (registry, clock).
293/// * `pinned_roots`: Trusted root `did:keri:` strings.
294/// * `raw_commit`: The raw git commit object bytes.
295/// * `provider`: Crypto provider for in-process signature verification.
296/// * `now`: Current time, injected at the boundary.
297///
298/// Usage:
299/// ```ignore
300/// let decision = verify_commit_with_policy(&ctx, &roots, commit, &provider, now).await?;
301/// assert!(decision.is_authorized());
302/// ```
303#[cfg(feature = "backend-git")]
304pub async fn verify_commit_with_policy(
305 ctx: &crate::context::AuthsContext,
306 pinned_roots: &[String],
307 raw_commit: &[u8],
308 provider: &dyn CryptoProvider,
309 now: DateTime<Utc>,
310) -> Result<CommitDecision, CommitTrustError> {
311 let verdict =
312 verify_commit_local(ctx.registry.as_ref(), pinned_roots, raw_commit, provider).await?;
313 let policy = match &verdict {
314 CommitVerdict::Valid {
315 signer_did,
316 root_did,
317 ..
318 } => evaluate_commit_policy(ctx, root_did, signer_did, now)?,
319 _ => PolicyOutcome::CryptoFailed,
320 };
321 Ok(CommitDecision { verdict, policy })
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 const ROOT: &str = "did:keri:Eroot00000000000000000000000000000000000000";
329 const DEVICE: &str = "did:key:z6MkDevice000000000000000000000000000000000";
330
331 fn commit_with_trailers(root: &str, device: &str) -> String {
332 format!(
333 "tree abc\nauthor T <t@e.com> 0 +0000\n\nsubject\n\nAuths-Id: {root}\nAuths-Device: {device}\n"
334 )
335 }
336
337 #[test]
338 fn extracts_both_trailers() {
339 let commit = commit_with_trailers(ROOT, DEVICE);
340 assert_eq!(
341 commit_signer_trailers(&commit),
342 Some((ROOT.to_string(), DEVICE.to_string()))
343 );
344 }
345
346 #[test]
347 fn missing_device_trailer_yields_none() {
348 let commit = "tree abc\n\nsubject\n\nAuths-Id: did:keri:Eroot\n";
349 assert!(commit_signer_trailers(commit).is_none());
350 }
351
352 #[test]
353 fn no_trailers_yields_none() {
354 assert!(commit_signer_trailers("tree abc\n\njust a message\n").is_none());
355 }
356
357 #[test]
358 fn last_trailer_wins_on_duplicates() {
359 let commit = format!(
360 "tree abc\n\nsubject\n\nAuths-Id: did:keri:Eold\nAuths-Device: {DEVICE}\nAuths-Id: {ROOT}\n"
361 );
362 assert_eq!(
363 commit_signer_trailers(&commit),
364 Some((ROOT.to_string(), DEVICE.to_string()))
365 );
366 }
367
368 #[cfg(feature = "backend-git")]
369 #[test]
370 fn duplicitous_root_is_not_authorized_even_when_valid_and_fresh() {
371 use auths_verifier::freshness::Freshness;
372 let decision = |duplicitous_root: bool| CommitDecision {
373 verdict: CommitVerdict::Valid {
374 signer_did: DEVICE.to_string(),
375 root_did: ROOT.to_string(),
376 duplicitous_root,
377 as_of: 1,
378 freshness: Freshness::Fresh,
379 },
380 policy: PolicyOutcome::NoPolicy,
381 };
382 // Fail-closed: a forked (duplicitous) root KEL is not trustworthy even with a valid,
383 // fresh signature and no org policy to deny it — the relying party cannot tell which
384 // branch is real.
385 assert!(
386 !decision(true).is_authorized(),
387 "a duplicitous root must fail closed"
388 );
389 // Control: the same commit without divergence is authorized.
390 assert!(
391 decision(false).is_authorized(),
392 "a clean, valid, fresh, policy-clear commit is authorized"
393 );
394 }
395}