1use auths_verifier::IdentityBundle;
12use chrono::{DateTime, Utc};
13
14use crate::keri::parse_trailers;
15
16#[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#[derive(Debug, thiserror::Error)]
30pub enum CommitTrustError {
31 #[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 #[error("identity bundle is not a usable trust anchor: {0}")]
41 BundleInvalid(String),
42
43 #[error("{role} KEL for {did} could not be resolved: {reason}")]
45 KelUnresolved {
46 role: &'static str,
48 did: String,
50 reason: String,
52 },
53
54 #[error("org policy evaluation failed: {0}")]
56 Policy(#[from] crate::domains::org::error::OrgError),
57}
58
59pub 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
86pub 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#[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#[derive(Debug, Clone)]
170pub enum PolicyOutcome {
171 CryptoFailed,
174 NoPolicy,
177 Evaluated(auths_id::policy::Decision),
179}
180
181#[cfg(feature = "backend-git")]
183#[derive(Debug, Clone)]
184pub struct CommitDecision {
185 pub verdict: CommitVerdict,
187 pub policy: PolicyOutcome,
189}
190
191#[cfg(feature = "backend-git")]
192impl CommitDecision {
193 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
208fn 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
229fn 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
270pub 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 crate::audit::emit_policy_decision(ctx, "commit", signer_did, &decision);
309 Ok(PolicyOutcome::Evaluated(decision))
310}
311
312#[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)] 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}