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