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/// Verify a commit against the locally-replayed KEL and the pinned trusted roots.
114///
115/// Local-only (no network, no witness gate): resolves the device and root KELs from
116/// `registry`, then replays them to decide whether the commit's signer is a device
117/// delegated under a root pinned in `pinned_roots`. This is the trust primitive the
118/// artifact-provenance path uses for the human-signed commit an ephemeral attestation
119/// is bound to. Witnessed and transport-aware verification (the `auths verify`
120/// command) layers `--remote`/`--oobi` and witness receipts on top of the same
121/// `auths_verifier` KEL primitive.
122///
123/// Args:
124/// * `registry`: The registry backend holding identity KELs.
125/// * `pinned_roots`: Trusted root `did:keri:` strings (from `.auths/roots`).
126/// * `raw_commit`: The raw git commit object bytes (with the `gpgsig` signature).
127/// * `provider`: Crypto provider for in-process signature verification.
128///
129/// Usage:
130/// ```ignore
131/// let verdict = verify_commit_local(&registry, &roots, commit_bytes, &provider).await?;
132/// assert!(verdict.is_valid());
133/// ```
134#[cfg(feature = "backend-git")]
135pub async fn verify_commit_local(
136    registry: &dyn RegistryBackend,
137    pinned_roots: &[String],
138    raw_commit: &[u8],
139    provider: &dyn CryptoProvider,
140) -> Result<CommitVerdict, CommitTrustError> {
141    let commit_str = String::from_utf8_lossy(raw_commit);
142    let (root_did, device_did) =
143        commit_signer_trailers(&commit_str).ok_or(CommitTrustError::MissingTrailers)?;
144
145    let chain = KelResolverChain::local(registry);
146    let device_kel =
147        chain
148            .resolve_kel(&device_did)
149            .map_err(|e| CommitTrustError::KelUnresolved {
150                role: "device",
151                did: device_did.clone(),
152                reason: e.to_string(),
153            })?;
154    let root_kel = chain
155        .resolve_kel(&root_did)
156        .map_err(|e| CommitTrustError::KelUnresolved {
157            role: "root",
158            did: root_did.clone(),
159            reason: e.to_string(),
160        })?;
161
162    Ok(verify_commit_against_kel(raw_commit, &device_kel, &root_kel, pinned_roots, provider).await)
163}
164
165/// The org-policy outcome layered on a cryptographically-valid commit verdict.
166///
167/// Policy is evaluated **after** the cryptographic verdict (fail-closed ordering: an
168/// unverified commit never reaches policy). It is a sum, never a bool.
169#[derive(Debug, Clone)]
170pub enum PolicyOutcome {
171    /// The cryptographic verdict was not `Valid`; policy was not evaluated (the commit
172    /// is already rejected by the verdict).
173    CryptoFailed,
174    /// The commit's root anchored no org policy — legacy allow, recorded (not silently
175    /// skipped). Pre-policy behavior is preserved for roots without a policy.
176    NoPolicy,
177    /// The org policy was evaluated; carries the typed [`auths_id::policy::Decision`].
178    Evaluated(auths_id::policy::Decision),
179}
180
181/// A commit's cryptographic verdict plus the org-policy decision layered on it.
182#[cfg(feature = "backend-git")]
183#[derive(Debug, Clone)]
184pub struct CommitDecision {
185    /// The cryptographic commit verdict (signature + KEL delegation + revocation).
186    pub verdict: CommitVerdict,
187    /// The org-policy outcome (only meaningful when `verdict.is_valid()`).
188    pub policy: PolicyOutcome,
189}
190
191#[cfg(feature = "backend-git")]
192impl CommitDecision {
193    /// True iff the commit is cryptographically valid **and** org policy authorizes it
194    /// (or the root anchored no policy). Fail-closed: any policy deny/indeterminate, or
195    /// a non-`Valid` verdict, is unauthorized.
196    pub fn is_authorized(&self) -> bool {
197        if !self.verdict.is_valid() {
198            return false;
199        }
200        match &self.policy {
201            PolicyOutcome::CryptoFailed => false,
202            PolicyOutcome::NoPolicy => true,
203            PolicyOutcome::Evaluated(decision) => decision.is_allowed(),
204        }
205    }
206}
207
208/// Map a delegated identifier's role marker to a policy signer type: an agent is an
209/// `Agent`; an unmarked device is the human's workstation (`Human`).
210fn signer_type_of(
211    ctx: &crate::context::AuthsContext,
212    root_prefix: &auths_id::keri::types::Prefix,
213    signer_prefix: &auths_id::keri::types::Prefix,
214) -> Result<auths_id::policy::SignerType, CommitTrustError> {
215    use auths_id::keri::delegation::{DelegatedRole, list_delegated_devices};
216    let delegated = list_delegated_devices(ctx.registry.as_ref(), root_prefix).map_err(|e| {
217        CommitTrustError::Policy(crate::domains::org::error::OrgError::Delegation(e))
218    })?;
219    let is_agent = delegated.iter().any(|d| {
220        d.device_prefix.as_str() == signer_prefix.as_str() && d.role == DelegatedRole::Agent
221    });
222    Ok(if is_agent {
223        auths_id::policy::SignerType::Agent
224    } else {
225        auths_id::policy::SignerType::Human
226    })
227}
228
229/// Build the policy evaluation context for a verified commit signer.
230///
231/// Caps/role come from the signer's delegator-anchored grant; `revoked = false`
232/// because a `Valid` verdict already certified the signer's authority was live at the
233/// signing position (positional revocation yields a non-`Valid` verdict). `signer_type`
234/// is read from the delegation role marker.
235fn commit_policy_context(
236    ctx: &crate::context::AuthsContext,
237    root_prefix: &auths_id::keri::types::Prefix,
238    signer_prefix: &auths_id::keri::types::Prefix,
239    now: DateTime<Utc>,
240) -> Result<auths_id::policy::EvalContext, CommitTrustError> {
241    use auths_id::policy::context_from_delegated_member;
242    use auths_verifier::core::Role;
243
244    let root_did = format!("did:keri:{}", root_prefix.as_str());
245    let signer_did = format!("did:keri:{}", signer_prefix.as_str());
246
247    let authority =
248        crate::domains::org::delegation::resolve_member_authority(ctx, root_prefix, signer_prefix)?;
249    let (role, caps, expires) = match &authority {
250        Some(a) => (
251            a.role.as_ref().map(Role::as_str),
252            a.capabilities.clone(),
253            a.expires_at,
254        ),
255        None => (None, Vec::new(), None),
256    };
257    let expires_dt = expires.and_then(|s| DateTime::from_timestamp(s, 0));
258
259    let mut eval_ctx =
260        context_from_delegated_member(&root_did, &signer_did, false, role, &caps, expires_dt, now)
261            .map_err(|e| {
262                CommitTrustError::Policy(crate::domains::org::error::OrgError::InvalidDid(
263                    e.to_string(),
264                ))
265            })?;
266    eval_ctx = eval_ctx.signer_type(signer_type_of(ctx, root_prefix, signer_prefix)?);
267    Ok(eval_ctx)
268}
269
270/// Evaluate the commit signer against the root's org policy (if any).
271///
272/// Loads the policy anchored by `root_did` and evaluates a grant-based context for
273/// `signer_did`. A root with no anchored policy yields [`PolicyOutcome::NoPolicy`]
274/// (legacy allow, recorded). Pure over the registry + injected `now`.
275///
276/// Args:
277/// * `ctx`: Auths context (registry).
278/// * `root_did`: The commit's `Auths-Id` (the policy authority).
279/// * `signer_did`: The commit's `Auths-Device` (the signer being gated).
280/// * `now`: Current time, injected at the boundary.
281pub fn evaluate_commit_policy(
282    ctx: &crate::context::AuthsContext,
283    root_did: &str,
284    signer_did: &str,
285    now: DateTime<Utc>,
286) -> Result<PolicyOutcome, CommitTrustError> {
287    use auths_id::keri::types::Prefix;
288    let root_prefix = Prefix::new_unchecked(
289        root_did
290            .strip_prefix("did:keri:")
291            .unwrap_or(root_did)
292            .to_string(),
293    );
294    let signer_prefix = Prefix::new_unchecked(
295        signer_did
296            .strip_prefix("did:keri:")
297            .unwrap_or(signer_did)
298            .to_string(),
299    );
300
301    let Some(policy) = crate::domains::org::policy::load_org_policy(ctx, &root_prefix)? else {
302        return Ok(PolicyOutcome::NoPolicy);
303    };
304    let eval_ctx = commit_policy_context(ctx, &root_prefix, &signer_prefix, now)?;
305    let decision = crate::domains::org::policy::evaluate_with_org_policy(&policy, &eval_ctx);
306    // A5: record every enforcement decision (allow + deny) for the audit trail. The
307    // gate stays pure; emission happens here at the caller.
308    crate::audit::emit_policy_decision(ctx, "commit", signer_did, &decision);
309    Ok(PolicyOutcome::Evaluated(decision))
310}
311
312/// Verify a commit cryptographically **and** against the root's org policy.
313///
314/// Runs [`verify_commit_local`] then, only on a `Valid` verdict, layers the root's org
315/// policy ([`evaluate_commit_policy`]). The result is a typed [`CommitDecision`]: the
316/// crypto verdict + the policy outcome. Fail-closed throughout — crypto gates policy,
317/// and policy is a sum, never a bool.
318///
319/// Args:
320/// * `ctx`: Auths context (registry, clock).
321/// * `pinned_roots`: Trusted root `did:keri:` strings.
322/// * `raw_commit`: The raw git commit object bytes.
323/// * `provider`: Crypto provider for in-process signature verification.
324/// * `now`: Current time, injected at the boundary.
325///
326/// Usage:
327/// ```ignore
328/// let decision = verify_commit_with_policy(&ctx, &roots, commit, &provider, now).await?;
329/// assert!(decision.is_authorized());
330/// ```
331#[cfg(feature = "backend-git")]
332pub async fn verify_commit_with_policy(
333    ctx: &crate::context::AuthsContext,
334    pinned_roots: &[String],
335    raw_commit: &[u8],
336    provider: &dyn CryptoProvider,
337    now: DateTime<Utc>,
338) -> Result<CommitDecision, CommitTrustError> {
339    let verdict =
340        verify_commit_local(ctx.registry.as_ref(), pinned_roots, raw_commit, provider).await?;
341    let policy = match &verdict {
342        CommitVerdict::Valid {
343            signer_did,
344            root_did,
345            ..
346        } => evaluate_commit_policy(ctx, root_did, signer_did, now)?,
347        _ => PolicyOutcome::CryptoFailed,
348    };
349    Ok(CommitDecision { verdict, policy })
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    const ROOT: &str = "did:keri:Eroot00000000000000000000000000000000000000";
357    const DEVICE: &str = "did:key:z6MkDevice000000000000000000000000000000000";
358
359    fn commit_with_trailers(root: &str, device: &str) -> String {
360        format!(
361            "tree abc\nauthor T <t@e.com> 0 +0000\n\nsubject\n\nAuths-Id: {root}\nAuths-Device: {device}\n"
362        )
363    }
364
365    #[test]
366    fn extracts_both_trailers() {
367        let commit = commit_with_trailers(ROOT, DEVICE);
368        assert_eq!(
369            commit_signer_trailers(&commit),
370            Some((ROOT.to_string(), DEVICE.to_string()))
371        );
372    }
373
374    #[test]
375    fn missing_device_trailer_yields_none() {
376        let commit = "tree abc\n\nsubject\n\nAuths-Id: did:keri:Eroot\n";
377        assert!(commit_signer_trailers(commit).is_none());
378    }
379
380    #[test]
381    fn no_trailers_yields_none() {
382        assert!(commit_signer_trailers("tree abc\n\njust a message\n").is_none());
383    }
384
385    #[test]
386    fn last_trailer_wins_on_duplicates() {
387        let commit = format!(
388            "tree abc\n\nsubject\n\nAuths-Id: did:keri:Eold\nAuths-Device: {DEVICE}\nAuths-Id: {ROOT}\n"
389        );
390        assert_eq!(
391            commit_signer_trailers(&commit),
392            Some((ROOT.to_string(), DEVICE.to_string()))
393        );
394    }
395
396    #[allow(clippy::disallowed_methods)] // INVARIANT: fixed test strings, never external input
397    fn test_bundle(did: &str, ts: DateTime<Utc>, ttl: u64) -> IdentityBundle {
398        IdentityBundle {
399            identity_did: auths_verifier::IdentityDID::new_unchecked(did.to_string()),
400            public_key_hex: auths_verifier::PublicKeyHex::new_unchecked("00".to_string()),
401            curve: auths_crypto::CurveType::P256,
402            attestation_chain: Vec::new(),
403            kel: Vec::new(),
404            bundle_timestamp: ts,
405            max_valid_for_secs: ttl,
406        }
407    }
408
409    fn fixed_time() -> DateTime<Utc> {
410        DateTime::<Utc>::from_timestamp(1_700_000_000, 0).expect("valid timestamp")
411    }
412
413    #[test]
414    fn fresh_bundle_yields_its_root_did() {
415        let t = fixed_time();
416        let bundle = test_bundle(ROOT, t, 3600);
417        let now = t + chrono::Duration::seconds(100);
418        assert_eq!(trusted_root_from_bundle(&bundle, now).expect("fresh"), ROOT);
419    }
420
421    #[test]
422    fn stale_bundle_fails_closed() {
423        let t = fixed_time();
424        let bundle = test_bundle(ROOT, t, 3600);
425        let now = t + chrono::Duration::seconds(7200);
426        assert!(matches!(
427            trusted_root_from_bundle(&bundle, now),
428            Err(CommitTrustError::BundleInvalid(_))
429        ));
430    }
431}