Skip to main content

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 auths_verifier::IdentityBundle;
12use chrono::{DateTime, Utc};
13
14use crate::keri::parse_trailers;
15
16// `verify_commit_local` resolves KELs through `KelResolverChain`, which is only
17// compiled with the git registry backend; gate it and its imports accordingly so
18// `auths-sdk` still builds without `backend-git` (the pure helpers below do not).
19#[cfg(feature = "backend-git")]
20use crate::keri::KelResolverChain;
21#[cfg(feature = "backend-git")]
22use crate::ports::RegistryBackend;
23#[cfg(feature = "backend-git")]
24use auths_crypto::CryptoProvider;
25#[cfg(feature = "backend-git")]
26use auths_verifier::{CommitVerdict, verify_commit_against_kel};
27
28/// Failure resolving commit trust before a verdict can be reached.
29#[derive(Debug, thiserror::Error)]
30pub enum CommitTrustError {
31    /// The commit carries no `Auths-Id` / `Auths-Device` trailer pair.
32    #[error(
33        "commit carries no Auths-Id/Auths-Device trailer — it was not signed by `auths` \
34         (or predates KEL-native signing)"
35    )]
36    MissingTrailers,
37
38    /// A supplied identity bundle was unreadable, malformed, or stale. Fails
39    /// closed — an unusable trust anchor must never silently downgrade trust.
40    #[error("identity bundle is not a usable trust anchor: {0}")]
41    BundleInvalid(String),
42
43    /// A signer's KEL could not be resolved from the registry.
44    #[error("{role} KEL for {did} could not be resolved: {reason}")]
45    KelUnresolved {
46        /// Which KEL failed to resolve — `"device"` or `"root"`.
47        role: &'static str,
48        /// The `did:keri:` / `did:key:` whose KEL could not be resolved.
49        did: String,
50        /// The underlying resolver error, rendered for display.
51        reason: String,
52    },
53
54    /// Loading or evaluating the root's org policy failed.
55    #[error("org policy evaluation failed: {0}")]
56    Policy(#[from] crate::domains::org::error::OrgError),
57}
58
59/// Extract `(root_did, device_did)` from a commit's `Auths-Id` / `Auths-Device`
60/// trailers. Returns `None` when either trailer is absent (a commit not signed by
61/// `auths`).
62///
63/// Args:
64/// * `raw_commit`: The raw git commit object (headers + message).
65///
66/// Usage:
67/// ```ignore
68/// if let Some((root, device)) = commit_signer_trailers(commit) { /* trusted? */ }
69/// ```
70pub fn commit_signer_trailers(raw_commit: &str) -> Option<(String, String)> {
71    let message = raw_commit
72        .split_once("\n\n")
73        .map(|(_, m)| m)
74        .unwrap_or(raw_commit);
75    let trailers = parse_trailers(message);
76    let find = |key: &str| {
77        trailers
78            .iter()
79            .rev()
80            .find(|(k, _)| k.eq_ignore_ascii_case(key))
81            .map(|(_, v)| v.trim().to_string())
82    };
83    Some((find("Auths-Id")?, find("Auths-Device")?))
84}
85
86/// The trusted root `did:keri:` a CI/stateless identity bundle pins.
87///
88/// The bundle's `identity_did` is a self-certifying KERI prefix (the prefix *is* the
89/// digest of its inception event), so trusting it as a pinned root is sound: any KEL
90/// resolved for that DID must self-certify to the same prefix or fail prefix-binding.
91/// The bundle is freshness-checked against `now` and fails **closed** — a stale or
92/// malformed anchor is rejected, never silently treated as "no constraint".
93///
94/// Args:
95/// * `bundle`: The parsed identity bundle supplied via `--identity-bundle`.
96/// * `now`: Current time, injected at the presentation boundary.
97///
98/// Usage:
99/// ```ignore
100/// let root = trusted_root_from_bundle(&bundle, clock.now())?;
101/// pinned_roots.push(root);
102/// ```
103pub fn trusted_root_from_bundle(
104    bundle: &IdentityBundle,
105    now: DateTime<Utc>,
106) -> Result<String, CommitTrustError> {
107    bundle
108        .check_freshness(now)
109        .map_err(|e| CommitTrustError::BundleInvalid(e.to_string()))?;
110    Ok(bundle.identity_did.to_string())
111}
112
113/// The verifier's own root identity DID, implicitly trusted for its own
114/// verifications.
115///
116/// Self-trust is the floor of the trust model: a verifier always trusts keys it
117/// itself controls, so the signer can verify their own commits and artifacts with
118/// zero pinning ceremony. Returns `None` when no local identity exists (behavior
119/// is then unchanged — only explicit pins apply). The returned DID resolves
120/// through the same KEL replay as any pinned root; this adds a root, never a
121/// shortcut around verification.
122///
123/// Args:
124/// * `ctx`: Auths context (identity storage).
125///
126/// Usage:
127/// ```ignore
128/// if let Some(own_root) = local_self_root(&ctx) {
129///     pinned_roots.push(own_root);
130/// }
131/// ```
132pub fn local_self_root(ctx: &crate::context::AuthsContext) -> Option<String> {
133    ctx.identity_storage
134        .load_identity()
135        .ok()
136        .map(|managed| managed.controller_did.into_inner())
137}
138
139/// Verify a commit against the locally-replayed KEL and the pinned trusted roots.
140///
141/// Local-only (no network, no witness gate): resolves the device and root KELs from
142/// `registry`, then replays them to decide whether the commit's signer is a device
143/// delegated under a root pinned in `pinned_roots`. This is the trust primitive the
144/// artifact-provenance path uses for the human-signed commit an ephemeral attestation
145/// is bound to. Witnessed and transport-aware verification (the `auths verify`
146/// command) layers `--remote`/`--oobi` and witness receipts on top of the same
147/// `auths_verifier` KEL primitive.
148///
149/// Args:
150/// * `registry`: The registry backend holding identity KELs.
151/// * `pinned_roots`: Trusted root `did:keri:` strings (from `.auths/roots`).
152/// * `raw_commit`: The raw git commit object bytes (with the `gpgsig` signature).
153/// * `provider`: Crypto provider for in-process signature verification.
154///
155/// Usage:
156/// ```ignore
157/// let verdict = verify_commit_local(&registry, &roots, commit_bytes, &provider).await?;
158/// assert!(verdict.is_valid());
159/// ```
160#[cfg(feature = "backend-git")]
161pub async fn verify_commit_local(
162    registry: &dyn RegistryBackend,
163    pinned_roots: &[String],
164    raw_commit: &[u8],
165    provider: &dyn CryptoProvider,
166) -> Result<CommitVerdict, CommitTrustError> {
167    let commit_str = String::from_utf8_lossy(raw_commit);
168    let (root_did, device_did) =
169        commit_signer_trailers(&commit_str).ok_or(CommitTrustError::MissingTrailers)?;
170
171    let chain = KelResolverChain::local(registry);
172    let device_kel =
173        chain
174            .resolve_kel(&device_did)
175            .map_err(|e| CommitTrustError::KelUnresolved {
176                role: "device",
177                did: device_did.clone(),
178                reason: e.to_string(),
179            })?;
180    let root_kel = chain
181        .resolve_kel(&root_did)
182        .map_err(|e| CommitTrustError::KelUnresolved {
183            role: "root",
184            did: root_did.clone(),
185            reason: e.to_string(),
186        })?;
187
188    Ok(verify_commit_against_kel(raw_commit, &device_kel, &root_kel, pinned_roots, provider).await)
189}
190
191/// The org-policy outcome layered on a cryptographically-valid commit verdict.
192///
193/// Policy is evaluated **after** the cryptographic verdict (fail-closed ordering: an
194/// unverified commit never reaches policy). It is a sum, never a bool.
195#[derive(Debug, Clone)]
196pub enum PolicyOutcome {
197    /// The cryptographic verdict was not `Valid`; policy was not evaluated (the commit
198    /// is already rejected by the verdict).
199    CryptoFailed,
200    /// The commit's root anchored no org policy — legacy allow, recorded (not silently
201    /// skipped). Pre-policy behavior is preserved for roots without a policy.
202    NoPolicy,
203    /// The org policy was evaluated; carries the typed [`auths_id::policy::Decision`].
204    Evaluated(auths_id::policy::Decision),
205}
206
207/// A commit's cryptographic verdict plus the org-policy decision layered on it.
208#[cfg(feature = "backend-git")]
209#[derive(Debug, Clone)]
210pub struct CommitDecision {
211    /// The cryptographic commit verdict (signature + KEL delegation + revocation).
212    pub verdict: CommitVerdict,
213    /// The org-policy outcome (only meaningful when `verdict.is_valid()`).
214    pub policy: PolicyOutcome,
215}
216
217#[cfg(feature = "backend-git")]
218impl CommitDecision {
219    /// True iff the commit is cryptographically valid **and** org policy authorizes it
220    /// (or the root anchored no policy). Fail-closed: any policy deny/indeterminate, or
221    /// a non-`Valid` verdict, is unauthorized.
222    pub fn is_authorized(&self) -> bool {
223        if !self.verdict.is_valid() {
224            return false;
225        }
226        match &self.policy {
227            PolicyOutcome::CryptoFailed => false,
228            PolicyOutcome::NoPolicy => true,
229            PolicyOutcome::Evaluated(decision) => decision.is_allowed(),
230        }
231    }
232}
233
234/// Map a delegated identifier's role marker to a policy signer type: an agent is an
235/// `Agent`; an unmarked device is the human's workstation (`Human`).
236fn signer_type_of(
237    ctx: &crate::context::AuthsContext,
238    root_prefix: &auths_id::keri::types::Prefix,
239    signer_prefix: &auths_id::keri::types::Prefix,
240) -> Result<auths_id::policy::SignerType, CommitTrustError> {
241    use auths_id::keri::delegation::{DelegatedRole, list_delegated_devices};
242    let delegated = list_delegated_devices(ctx.registry.as_ref(), root_prefix).map_err(|e| {
243        CommitTrustError::Policy(crate::domains::org::error::OrgError::Delegation(e))
244    })?;
245    let is_agent = delegated.iter().any(|d| {
246        d.device_prefix.as_str() == signer_prefix.as_str() && d.role == DelegatedRole::Agent
247    });
248    Ok(if is_agent {
249        auths_id::policy::SignerType::Agent
250    } else {
251        auths_id::policy::SignerType::Human
252    })
253}
254
255/// Build the policy evaluation context for a verified commit signer.
256///
257/// Caps/role come from the signer's delegator-anchored grant; `revoked = false`
258/// because a `Valid` verdict already certified the signer's authority was live at the
259/// signing position (positional revocation yields a non-`Valid` verdict). `signer_type`
260/// is read from the delegation role marker.
261fn commit_policy_context(
262    ctx: &crate::context::AuthsContext,
263    root_prefix: &auths_id::keri::types::Prefix,
264    signer_prefix: &auths_id::keri::types::Prefix,
265    now: DateTime<Utc>,
266) -> Result<auths_id::policy::EvalContext, CommitTrustError> {
267    use auths_id::policy::context_from_delegated_member;
268    use auths_verifier::core::Role;
269
270    let root_did = format!("did:keri:{}", root_prefix.as_str());
271    let signer_did = format!("did:keri:{}", signer_prefix.as_str());
272
273    let authority =
274        crate::domains::org::delegation::resolve_member_authority(ctx, root_prefix, signer_prefix)?;
275    let (role, caps, expires) = match &authority {
276        Some(a) => (
277            a.role.as_ref().map(Role::as_str),
278            a.capabilities.clone(),
279            a.expires_at,
280        ),
281        None => (None, Vec::new(), None),
282    };
283    let expires_dt = expires.and_then(|s| DateTime::from_timestamp(s, 0));
284
285    let mut eval_ctx =
286        context_from_delegated_member(&root_did, &signer_did, false, role, &caps, expires_dt, now)
287            .map_err(|e| {
288                CommitTrustError::Policy(crate::domains::org::error::OrgError::InvalidDid(
289                    e.to_string(),
290                ))
291            })?;
292    eval_ctx = eval_ctx.signer_type(signer_type_of(ctx, root_prefix, signer_prefix)?);
293    Ok(eval_ctx)
294}
295
296/// Evaluate the commit signer against the root's org policy (if any).
297///
298/// Loads the policy anchored by `root_did` and evaluates a grant-based context for
299/// `signer_did`. A root with no anchored policy yields [`PolicyOutcome::NoPolicy`]
300/// (legacy allow, recorded). Pure over the registry + injected `now`.
301///
302/// Args:
303/// * `ctx`: Auths context (registry).
304/// * `root_did`: The commit's `Auths-Id` (the policy authority).
305/// * `signer_did`: The commit's `Auths-Device` (the signer being gated).
306/// * `now`: Current time, injected at the boundary.
307pub fn evaluate_commit_policy(
308    ctx: &crate::context::AuthsContext,
309    root_did: &str,
310    signer_did: &str,
311    now: DateTime<Utc>,
312) -> Result<PolicyOutcome, CommitTrustError> {
313    use auths_id::keri::types::Prefix;
314    let root_prefix = Prefix::new_unchecked(
315        root_did
316            .strip_prefix("did:keri:")
317            .unwrap_or(root_did)
318            .to_string(),
319    );
320    let signer_prefix = Prefix::new_unchecked(
321        signer_did
322            .strip_prefix("did:keri:")
323            .unwrap_or(signer_did)
324            .to_string(),
325    );
326
327    let Some(policy) = crate::domains::org::policy::load_org_policy(ctx, &root_prefix)? else {
328        return Ok(PolicyOutcome::NoPolicy);
329    };
330    let eval_ctx = commit_policy_context(ctx, &root_prefix, &signer_prefix, now)?;
331    let decision = crate::domains::org::policy::evaluate_with_org_policy(&policy, &eval_ctx);
332    // A5: record every enforcement decision (allow + deny) for the audit trail. The
333    // gate stays pure; emission happens here at the caller.
334    crate::audit::emit_policy_decision(ctx, "commit", signer_did, &decision);
335    Ok(PolicyOutcome::Evaluated(decision))
336}
337
338/// Verify a commit cryptographically **and** against the root's org policy.
339///
340/// Runs [`verify_commit_local`] then, only on a `Valid` verdict, layers the root's org
341/// policy ([`evaluate_commit_policy`]). The result is a typed [`CommitDecision`]: the
342/// crypto verdict + the policy outcome. Fail-closed throughout — crypto gates policy,
343/// and policy is a sum, never a bool.
344///
345/// Args:
346/// * `ctx`: Auths context (registry, clock).
347/// * `pinned_roots`: Trusted root `did:keri:` strings.
348/// * `raw_commit`: The raw git commit object bytes.
349/// * `provider`: Crypto provider for in-process signature verification.
350/// * `now`: Current time, injected at the boundary.
351///
352/// Usage:
353/// ```ignore
354/// let decision = verify_commit_with_policy(&ctx, &roots, commit, &provider, now).await?;
355/// assert!(decision.is_authorized());
356/// ```
357#[cfg(feature = "backend-git")]
358pub async fn verify_commit_with_policy(
359    ctx: &crate::context::AuthsContext,
360    pinned_roots: &[String],
361    raw_commit: &[u8],
362    provider: &dyn CryptoProvider,
363    now: DateTime<Utc>,
364) -> Result<CommitDecision, CommitTrustError> {
365    let verdict =
366        verify_commit_local(ctx.registry.as_ref(), pinned_roots, raw_commit, provider).await?;
367    let policy = match &verdict {
368        CommitVerdict::Valid {
369            signer_did,
370            root_did,
371            ..
372        } => evaluate_commit_policy(ctx, root_did, signer_did, now)?,
373        _ => PolicyOutcome::CryptoFailed,
374    };
375    Ok(CommitDecision { verdict, policy })
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    const ROOT: &str = "did:keri:Eroot00000000000000000000000000000000000000";
383    const DEVICE: &str = "did:key:z6MkDevice000000000000000000000000000000000";
384
385    fn commit_with_trailers(root: &str, device: &str) -> String {
386        format!(
387            "tree abc\nauthor T <t@e.com> 0 +0000\n\nsubject\n\nAuths-Id: {root}\nAuths-Device: {device}\n"
388        )
389    }
390
391    #[test]
392    fn extracts_both_trailers() {
393        let commit = commit_with_trailers(ROOT, DEVICE);
394        assert_eq!(
395            commit_signer_trailers(&commit),
396            Some((ROOT.to_string(), DEVICE.to_string()))
397        );
398    }
399
400    #[test]
401    fn missing_device_trailer_yields_none() {
402        let commit = "tree abc\n\nsubject\n\nAuths-Id: did:keri:Eroot\n";
403        assert!(commit_signer_trailers(commit).is_none());
404    }
405
406    #[test]
407    fn no_trailers_yields_none() {
408        assert!(commit_signer_trailers("tree abc\n\njust a message\n").is_none());
409    }
410
411    #[test]
412    fn last_trailer_wins_on_duplicates() {
413        let commit = format!(
414            "tree abc\n\nsubject\n\nAuths-Id: did:keri:Eold\nAuths-Device: {DEVICE}\nAuths-Id: {ROOT}\n"
415        );
416        assert_eq!(
417            commit_signer_trailers(&commit),
418            Some((ROOT.to_string(), DEVICE.to_string()))
419        );
420    }
421
422    #[allow(clippy::disallowed_methods)] // INVARIANT: fixed test strings, never external input
423    fn test_bundle(did: &str, ts: DateTime<Utc>, ttl: u64) -> IdentityBundle {
424        IdentityBundle {
425            identity_did: auths_verifier::IdentityDID::new_unchecked(did.to_string()),
426            public_key_hex: auths_verifier::PublicKeyHex::new_unchecked("00".to_string()),
427            curve: auths_crypto::CurveType::P256,
428            attestation_chain: Vec::new(),
429            kel: Vec::new(),
430            bundle_timestamp: ts,
431            max_valid_for_secs: ttl,
432        }
433    }
434
435    fn fixed_time() -> DateTime<Utc> {
436        DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("valid timestamp")
437    }
438
439    #[test]
440    fn fresh_bundle_yields_its_root_did() {
441        let t = fixed_time();
442        let bundle = test_bundle(ROOT, t, 3600);
443        let now = t + chrono::Duration::seconds(100);
444        assert_eq!(trusted_root_from_bundle(&bundle, now).expect("fresh"), ROOT);
445    }
446
447    #[test]
448    fn stale_bundle_fails_closed() {
449        let t = fixed_time();
450        let bundle = test_bundle(ROOT, t, 3600);
451        let now = t + chrono::Duration::seconds(7200);
452        assert!(matches!(
453            trusted_root_from_bundle(&bundle, now),
454            Err(CommitTrustError::BundleInvalid(_))
455        ));
456    }
457}