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
113pub 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#[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#[derive(Debug, Clone)]
196pub enum PolicyOutcome {
197 CryptoFailed,
200 NoPolicy,
203 Evaluated(auths_id::policy::Decision),
205}
206
207#[cfg(feature = "backend-git")]
209#[derive(Debug, Clone)]
210pub struct CommitDecision {
211 pub verdict: CommitVerdict,
213 pub policy: PolicyOutcome,
215}
216
217#[cfg(feature = "backend-git")]
218impl CommitDecision {
219 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
234fn 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
255fn 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
296pub 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 crate::audit::emit_policy_decision(ctx, "commit", signer_did, &decision);
335 Ok(PolicyOutcome::Evaluated(decision))
336}
337
338#[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)] 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}