1use acdp_crypto::fingerprint::{fingerprint_ed25519, fingerprint_p256_sec1};
27use acdp_did::DidDocument;
28use acdp_primitives::error::AcdpError;
29use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
30use base64::Engine;
31use chrono::{DateTime, Utc};
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
34use sha2::{Digest, Sha256};
35
36use crate::body::{Body, Signature};
37use crate::primitives::{AgentDid, ContentHash, CtxId};
38use crate::serde_helpers::de_present;
39
40pub use acdp_did::KEYLESS_FRAGMENT_PREFIX;
41pub use acdp_primitives::keyless::KeylessReason;
42
43pub const KEY_ATTESTATION_TYPE: &str = "acdp:key-attestation";
45pub const KEY_ATTESTATION_SCHEMA_URI: &str =
47 "https://schemas.acdp.io/v0.1.0/acdp-key-attestation.schema.json";
48pub const ATTESTATION_VERSION: &str = "acdp-key-attestation/1";
50pub const REQUEST_VERSION: &str = "acdp-key-attestation-request/1";
52pub const POP_VERSION: &str = "acdp-key-attestation-pop/1";
54pub const KEYLESS_SERVICE_TYPE: &str = "AcdpKeylessSigning";
56pub const MAX_WINDOW_SECONDS: i64 = 3600;
58pub const DEFAULT_TTL_SECONDS: u32 = 600;
60pub const REGISTRY_PROFILE: &str = "acdp-registry-keyless";
62pub const CONSUMER_PROFILE: &str = "acdp-consumer-keyless";
64
65fn invalid(msg: impl std::fmt::Display) -> AcdpError {
66 AcdpError::SchemaViolation(format!("key attestation: {msg}"))
67}
68
69pub fn is_keyless_key_id(key_id: &str) -> bool {
75 key_id
76 .split_once('#')
77 .is_some_and(|(_, frag)| frag.starts_with(KEYLESS_FRAGMENT_PREFIX))
78}
79
80pub fn keyless_key_id(controller: &str, key_fingerprint: &str) -> Result<String, AcdpError> {
83 let hex = key_fingerprint
84 .strip_prefix("sha256:")
85 .filter(|h| is_lower_hex_64(h))
86 .ok_or_else(|| invalid(format!("'{key_fingerprint}' is not a sha256 fingerprint")))?;
87 Ok(format!("{controller}#{KEYLESS_FRAGMENT_PREFIX}{hex}"))
88}
89
90pub fn token_hash(token: &str) -> String {
92 format!("sha256:{}", hex::encode(Sha256::digest(token.as_bytes())))
93}
94
95fn is_lower_hex_64(s: &str) -> bool {
96 s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
97}
98
99fn is_sha256(s: &str) -> bool {
100 s.strip_prefix("sha256:").is_some_and(is_lower_hex_64)
101}
102
103fn is_did_web(s: &str) -> bool {
104 s.strip_prefix("did:web:").is_some_and(|rest| {
105 !rest.is_empty()
106 && rest
107 .bytes()
108 .all(|b| b.is_ascii_alphanumeric() || b"._:%-".contains(&b))
109 })
110}
111
112fn at_least_0_5(v: &str) -> bool {
114 let parts: Vec<u64> = v.split('.').filter_map(|p| p.parse().ok()).collect();
115 parts.len() == 3 && (parts[0], parts[1]) >= (0, 5)
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct Jwk {
125 pub crv: String,
127 pub kty: String,
129 pub x: String,
131 #[serde(
133 default,
134 deserialize_with = "de_present",
135 skip_serializing_if = "Option::is_none"
136 )]
137 pub y: Option<String>,
138}
139
140enum KeyBytes {
142 Ed25519([u8; 32]),
143 P256(Vec<u8>),
145}
146
147fn b64u_32(field: &str, s: &str) -> Result<[u8; 32], AcdpError> {
148 if s.len() != 43 {
149 return Err(invalid(format!(
150 "public_key_jwk.{field} is not 32 base64url bytes"
151 )));
152 }
153 URL_SAFE_NO_PAD
154 .decode(s)
155 .ok()
156 .and_then(|b| <[u8; 32]>::try_from(b).ok())
157 .ok_or_else(|| invalid(format!("public_key_jwk.{field} is not 32 base64url bytes")))
158}
159
160impl Jwk {
161 pub fn from_ed25519(public_key: &[u8; 32]) -> Self {
163 Jwk {
164 crv: "Ed25519".into(),
165 kty: "OKP".into(),
166 x: URL_SAFE_NO_PAD.encode(public_key),
167 y: None,
168 }
169 }
170
171 pub fn from_p256_sec1(sec1: &[u8]) -> Result<Self, AcdpError> {
173 let vk = p256::ecdsa::VerifyingKey::from_sec1_bytes(sec1)
174 .map_err(|e| invalid(format!("P-256 key: {e}")))?;
175 let point = vk.to_sec1_point(false);
176 let raw = point.as_bytes();
177 Ok(Jwk {
178 crv: "P-256".into(),
179 kty: "EC".into(),
180 x: URL_SAFE_NO_PAD.encode(&raw[1..33]),
181 y: Some(URL_SAFE_NO_PAD.encode(&raw[33..65])),
182 })
183 }
184
185 fn key_bytes(&self) -> Result<KeyBytes, AcdpError> {
186 match (self.kty.as_str(), self.crv.as_str(), &self.y) {
187 ("OKP", "Ed25519", None) => Ok(KeyBytes::Ed25519(b64u_32("x", &self.x)?)),
188 ("EC", "P-256", Some(y)) => {
189 let mut sec1 = vec![0x04];
190 sec1.extend_from_slice(&b64u_32("x", &self.x)?);
191 sec1.extend_from_slice(&b64u_32("y", y)?);
192 p256::ecdsa::VerifyingKey::from_sec1_bytes(&sec1)
193 .map_err(|e| invalid(format!("public_key_jwk is not a P-256 point: {e}")))?;
194 Ok(KeyBytes::P256(sec1))
195 }
196 _ => Err(invalid(
197 "public_key_jwk must be {kty: OKP, crv: Ed25519, x} or {kty: EC, crv: P-256, x, y}",
198 )),
199 }
200 }
201
202 pub fn algorithm(&self) -> Result<&'static str, AcdpError> {
204 Ok(match self.key_bytes()? {
205 KeyBytes::Ed25519(_) => "ed25519",
206 KeyBytes::P256(_) => "ecdsa-p256",
207 })
208 }
209
210 pub fn fingerprint(&self) -> Result<String, AcdpError> {
213 match self.key_bytes()? {
214 KeyBytes::Ed25519(k) => Ok(fingerprint_ed25519(&k)),
215 KeyBytes::P256(sec1) => fingerprint_p256_sec1(&sec1),
216 }
217 }
218
219 pub fn thumbprint(&self) -> Result<String, AcdpError> {
222 self.key_bytes()?;
223 let canon = acdp_jcs::canonicalize(self)?;
224 Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(canon)))
225 }
226
227 pub fn verify(
230 &self,
231 algorithm: &str,
232 signature_b64: &str,
233 message: &str,
234 ) -> Result<(), AcdpError> {
235 match (self.key_bytes()?, algorithm) {
236 (KeyBytes::Ed25519(k), "ed25519") => {
237 acdp_crypto::verify_ed25519(&k, signature_b64, message)
238 }
239 (KeyBytes::P256(sec1), "ecdsa-p256") => {
240 acdp_crypto::verify_ecdsa_p256(&sec1, signature_b64, message)
241 }
242 _ => Err(AcdpError::InvalidSignature(format!(
243 "signature algorithm '{algorithm}' does not match the attested key type"
244 ))),
245 }
246 }
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
254#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
255pub enum WorkloadIdentity {
256 Oidc {
258 issuer: String,
260 subject: String,
262 },
263 Spiffe {
265 subject: String,
267 },
268}
269
270fn check_oidc(issuer: &str, subject: &str) -> Result<(), AcdpError> {
271 let issuer_ok = issuer.len() <= 512
272 && issuer.strip_prefix("https://").is_some_and(|r| {
273 !r.is_empty() && !r.chars().any(|c| c.is_whitespace() || c == '#' || c == '?')
274 });
275 if !issuer_ok {
276 return Err(invalid(format!(
277 "identity.issuer '{issuer}' is not an https issuer URI"
278 )));
279 }
280 if subject.is_empty() || subject.len() > 512 {
281 return Err(invalid("identity.subject must be 1 to 512 characters"));
282 }
283 Ok(())
284}
285
286pub fn spiffe_trust_domain(subject: &str) -> Option<&str> {
288 let rest = subject.strip_prefix("spiffe://")?;
289 let domain = rest.split('/').next()?;
290 let domain_ok = !domain.is_empty()
291 && domain
292 .bytes()
293 .all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'-'));
294 let path_ok = rest[domain.len()..].split('/').skip(1).all(|seg| {
295 !seg.is_empty()
296 && seg
297 .bytes()
298 .all(|b| b.is_ascii_alphanumeric() || b"._~%!$&'()*+,;=:@-".contains(&b))
299 });
300 (domain_ok && path_ok && subject.len() <= 2048).then_some(domain)
301}
302
303fn check_spiffe(subject: &str) -> Result<(), AcdpError> {
304 spiffe_trust_domain(subject)
305 .map(|_| ())
306 .ok_or_else(|| invalid(format!("identity.subject '{subject}' is not a SPIFFE ID")))
307}
308
309impl WorkloadIdentity {
310 pub fn validate(&self) -> Result<(), AcdpError> {
312 match self {
313 Self::Oidc { issuer, subject } => check_oidc(issuer, subject),
314 Self::Spiffe { subject } => check_spiffe(subject),
315 }
316 }
317}
318
319#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
323pub enum IdentityDescriptor {
324 Oidc {
326 issuer: String,
328 subject: String,
330 proof: String,
332 },
333 Spiffe {
335 subject: String,
337 svid_type: String,
339 proof: String,
341 },
342}
343
344impl std::fmt::Debug for IdentityDescriptor {
345 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
346 match self {
347 Self::Oidc {
348 issuer, subject, ..
349 } => f
350 .debug_struct("Oidc")
351 .field("issuer", issuer)
352 .field("subject", subject)
353 .field("proof", &"<redacted>")
354 .finish(),
355 Self::Spiffe {
356 subject, svid_type, ..
357 } => f
358 .debug_struct("Spiffe")
359 .field("subject", subject)
360 .field("svid_type", svid_type)
361 .field("proof", &"<redacted>")
362 .finish(),
363 }
364 }
365}
366
367fn is_compact_jwt(s: &str) -> bool {
368 let parts: Vec<&str> = s.split('.').collect();
369 s.len() <= 16384
370 && parts.len() == 3
371 && parts.iter().all(|p| {
372 !p.is_empty()
373 && p.bytes()
374 .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
375 })
376}
377
378impl IdentityDescriptor {
379 pub fn oidc(
381 issuer: impl Into<String>,
382 subject: impl Into<String>,
383 token: impl Into<String>,
384 ) -> Self {
385 Self::Oidc {
386 issuer: issuer.into(),
387 subject: subject.into(),
388 proof: token.into(),
389 }
390 }
391
392 pub fn spiffe_jwt(subject: impl Into<String>, svid: impl Into<String>) -> Self {
394 Self::Spiffe {
395 subject: subject.into(),
396 svid_type: "jwt-svid".into(),
397 proof: svid.into(),
398 }
399 }
400
401 pub fn token(&self) -> &str {
403 match self {
404 Self::Oidc { proof, .. } | Self::Spiffe { proof, .. } => proof,
405 }
406 }
407
408 pub fn identity(&self) -> WorkloadIdentity {
410 match self {
411 Self::Oidc {
412 issuer, subject, ..
413 } => WorkloadIdentity::Oidc {
414 issuer: issuer.clone(),
415 subject: subject.clone(),
416 },
417 Self::Spiffe { subject, .. } => WorkloadIdentity::Spiffe {
418 subject: subject.clone(),
419 },
420 }
421 }
422
423 pub fn validate(&self) -> Result<(), AcdpError> {
426 match self {
427 Self::Oidc {
428 issuer,
429 subject,
430 proof,
431 } => {
432 check_oidc(issuer, subject)?;
433 if !is_compact_jwt(proof) {
434 return Err(invalid("identity.proof is not a compact JWT"));
435 }
436 }
437 Self::Spiffe {
438 subject,
439 svid_type,
440 proof,
441 } => {
442 check_spiffe(subject)?;
443 if svid_type != "jwt-svid" {
444 return Err(invalid(format!(
445 "identity.svid_type '{svid_type}' is not jwt-svid (X509-SVIDs are not accepted)"
446 )));
447 }
448 if !is_compact_jwt(proof) {
449 return Err(invalid("identity.proof is not a compact JWT-SVID"));
450 }
451 }
452 }
453 Ok(())
454 }
455}
456
457#[derive(Debug, Clone, PartialEq, Eq)]
459pub struct ListedIdentity {
460 pub identity: WorkloadIdentity,
462 pub org_wide: bool,
464}
465
466#[derive(Debug, Clone, Default, PartialEq, Eq)]
468pub struct ListedIdentities(Vec<ListedIdentity>);
469
470impl ListedIdentities {
471 pub fn of(doc: &DidDocument) -> Self {
475 let mut services = doc
476 .services
477 .iter()
478 .filter(|s| s.get("type").and_then(Value::as_str) == Some(KEYLESS_SERVICE_TYPE));
479 let (Some(service), None) = (services.next(), services.next()) else {
480 return Self::default();
481 };
482 let Some(entries) = service
483 .get("serviceEndpoint")
484 .and_then(|e| e.get("identities"))
485 .and_then(Value::as_array)
486 else {
487 return Self::default();
488 };
489 Self(entries.iter().filter_map(listed_entry).collect())
490 }
491
492 pub fn find(&self, identity: &WorkloadIdentity) -> Option<&ListedIdentity> {
494 self.0.iter().find(|e| &e.identity == identity)
495 }
496
497 pub fn entries(&self) -> &[ListedIdentity] {
499 &self.0
500 }
501}
502
503fn listed_entry(entry: &Value) -> Option<ListedIdentity> {
504 let obj = entry.as_object()?;
505 let org_wide = match obj.get("org_wide") {
506 None => false,
507 Some(v) => v.as_bool()?,
508 };
509 let mut hint = obj.clone();
510 hint.remove("org_wide");
511 let identity: WorkloadIdentity = serde_json::from_value(Value::Object(hint)).ok()?;
512 identity.validate().ok()?;
513 Some(ListedIdentity { identity, org_wide })
514}
515
516#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
523#[serde(deny_unknown_fields)]
524pub struct KeyAttestation {
525 pub attestation_version: String,
527 pub controller: String,
529 pub identity: WorkloadIdentity,
531 pub org_wide: bool,
533 pub key_id: String,
535 pub algorithm: String,
537 pub public_key_jwk: Jwk,
539 pub key_fingerprint: String,
541 #[serde(with = "crate::trust_list::ms")]
543 pub not_before: DateTime<Utc>,
544 #[serde(with = "crate::trust_list::ms")]
546 pub not_after: DateTime<Utc>,
547 pub token_hash: String,
549}
550
551impl KeyAttestation {
552 pub fn from_metadata(metadata: &Value) -> Result<Self, AcdpError> {
556 let att = Self::deserialize(metadata).map_err(invalid)?;
557 att.validate()?;
558 Ok(att)
559 }
560
561 fn validate(&self) -> Result<(), AcdpError> {
562 if self.attestation_version != ATTESTATION_VERSION {
563 return Err(invalid(format!(
564 "attestation_version '{}' is not '{ATTESTATION_VERSION}'",
565 self.attestation_version
566 )));
567 }
568 if !is_did_web(&self.controller) {
569 return Err(invalid(format!(
570 "controller '{}' is not a did:web DID",
571 self.controller
572 )));
573 }
574 self.identity.validate()?;
575 if self.public_key_jwk.algorithm()? != self.algorithm {
576 return Err(invalid(format!(
577 "algorithm '{}' does not match the public_key_jwk key type",
578 self.algorithm
579 )));
580 }
581 if !is_sha256(&self.key_fingerprint)
582 || self.public_key_jwk.fingerprint()? != self.key_fingerprint
583 {
584 return Err(invalid(
585 "key_fingerprint is not the fingerprint of public_key_jwk",
586 ));
587 }
588 if self.key_id != keyless_key_id(&self.controller, &self.key_fingerprint)? {
589 return Err(invalid(
590 "key_id is not <controller>#acdp-kl-<fingerprint hex>",
591 ));
592 }
593 let span = (self.not_after - self.not_before).num_milliseconds();
594 if span <= 0 || span > MAX_WINDOW_SECONDS * 1000 {
595 return Err(invalid(
596 "the window must be non-empty and at most 3600 seconds",
597 ));
598 }
599 if !is_sha256(&self.token_hash) {
600 return Err(invalid("token_hash is not a sha256 digest"));
601 }
602 Ok(())
603 }
604
605 pub fn from_body(body: &Body) -> Result<Self, AcdpError> {
613 if !matches!(&body.context_type, crate::primitives::ContextType::Custom(t) if t == KEY_ATTESTATION_TYPE)
614 {
615 return Err(invalid(format!("type is not {KEY_ATTESTATION_TYPE}")));
616 }
617 if body.visibility != crate::primitives::Visibility::Public {
618 return Err(invalid("an attestation must be public"));
619 }
620 if body.version != 1
621 || body.supersedes.is_some()
622 || !body.data_refs.is_empty()
623 || !body.derived_from.is_empty()
624 {
625 return Err(invalid(
626 "an attestation is version 1 with no supersedes, data_refs or derived_from",
627 ));
628 }
629 let registry = acdp_did::authority_to_did_web(body.ctx_id.authority());
630 if body.agent_id.as_str() != registry {
631 return Err(invalid(format!(
632 "agent_id '{}' is not the attesting registry '{registry}' of its ctx_id",
633 body.agent_id
634 )));
635 }
636 if body.schema_uri.as_deref() != Some(KEY_ATTESTATION_SCHEMA_URI) {
637 return Err(invalid(format!(
638 "schema_uri is not {KEY_ATTESTATION_SCHEMA_URI}"
639 )));
640 }
641 if !body.acdp_version.as_deref().is_some_and(at_least_0_5) {
642 return Err(invalid("acdp_version must be at least 0.5.0"));
643 }
644 let metadata = body
645 .metadata
646 .as_ref()
647 .ok_or_else(|| invalid("metadata is missing"))?;
648 Self::from_metadata(metadata)
649 }
650
651 pub fn binds(&self, agent_id: &AgentDid, signature: &Signature) -> bool {
654 agent_id.as_str() == self.controller
655 && signature.key_id == self.key_id
656 && signature.algorithm == self.algorithm
657 }
658
659 pub fn window_contains(&self, t: DateTime<Utc>) -> bool {
661 self.not_before <= t && t < self.not_after
662 }
663
664 pub fn signer(&self) -> KeylessSigner {
666 KeylessSigner::Keyless {
667 identity: self.identity.clone(),
668 org_wide: self.org_wide,
669 }
670 }
671}
672
673#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
679#[serde(deny_unknown_fields)]
680pub struct KeyAttestationRef {
681 pub ctx_id: CtxId,
683 pub content_hash: ContentHash,
685}
686
687impl KeyAttestationRef {
688 pub fn validate(&self) -> Result<(), AcdpError> {
690 CtxId::parse(self.ctx_id.as_str())?;
691 ContentHash::parse(self.content_hash.as_str())?;
692 Ok(())
693 }
694}
695
696pub const KEY_ATTESTATION_FIELD: &str = "key_attestation";
698
699impl Body {
700 pub fn key_attestation(&self) -> Result<Option<KeyAttestationRef>, AcdpError> {
704 let Some(raw) = self.extensions.get(KEY_ATTESTATION_FIELD) else {
705 return Ok(None);
706 };
707 let r = KeyAttestationRef::deserialize(raw)
708 .map_err(|e| AcdpError::SchemaViolation(format!("key_attestation: {e}")))?;
709 r.validate()?;
710 Ok(Some(r))
711 }
712}
713
714pub fn check_key_attestation_presence(key_id: &str, present: bool) -> Result<(), AcdpError> {
719 if present && !is_keyless_key_id(key_id) {
720 return Err(AcdpError::SchemaViolation(format!(
721 "key_attestation is present but signature.key_id '{key_id}' is not a keyless key id \
722 (RFC-ACDP-0021 §6.2)"
723 )));
724 }
725 Ok(())
726}
727
728pub fn pop_preimage(
733 controller: &str,
734 registry_did: &str,
735 token_hash: &str,
736 key_fingerprint: &str,
737) -> Result<String, AcdpError> {
738 let pre = serde_json::json!({
739 "pop_version": POP_VERSION,
740 "controller": controller,
741 "registry_did": registry_did,
742 "token_hash": token_hash,
743 "key_fingerprint": key_fingerprint,
744 });
745 String::from_utf8(acdp_jcs::canonicalize(&pre)?)
746 .map_err(|e| AcdpError::Serialization(e.to_string()))
747}
748
749#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
752#[serde(deny_unknown_fields)]
753pub struct KeyAttestationRequest {
754 pub request_version: String,
756 pub controller: String,
758 pub identity: IdentityDescriptor,
760 pub algorithm: String,
762 pub public_key_jwk: Jwk,
764 pub pop: String,
766 #[serde(
768 default,
769 deserialize_with = "de_present",
770 skip_serializing_if = "Option::is_none"
771 )]
772 pub requested_ttl_seconds: Option<u32>,
773}
774
775impl KeyAttestationRequest {
776 pub fn validate(&self) -> Result<(), AcdpError> {
779 if self.request_version != REQUEST_VERSION {
780 return Err(invalid(format!(
781 "request_version '{}' is not '{REQUEST_VERSION}'",
782 self.request_version
783 )));
784 }
785 if !is_did_web(&self.controller) {
786 return Err(invalid(format!(
787 "controller '{}' is not a did:web DID",
788 self.controller
789 )));
790 }
791 self.identity.validate()?;
792 if !matches!(self.algorithm.as_str(), "ed25519" | "ecdsa-p256") {
793 return Err(invalid(format!(
794 "algorithm '{}' is not supported",
795 self.algorithm
796 )));
797 }
798 self.public_key_jwk.algorithm()?;
799 let pop_ok = !self.pop.is_empty() && STANDARD.decode(&self.pop).is_ok();
800 if !pop_ok {
801 return Err(invalid("pop is not base64"));
802 }
803 if self
804 .requested_ttl_seconds
805 .is_some_and(|t| t == 0 || i64::from(t) > MAX_WINDOW_SECONDS)
806 {
807 return Err(invalid("requested_ttl_seconds must be 1 to 3600"));
808 }
809 Ok(())
810 }
811
812 pub fn token_hash(&self) -> String {
814 token_hash(self.identity.token())
815 }
816
817 pub fn pop_preimage(&self, registry_did: &str) -> Result<String, AcdpError> {
819 pop_preimage(
820 &self.controller,
821 registry_did,
822 &self.token_hash(),
823 &self.public_key_jwk.fingerprint()?,
824 )
825 }
826
827 pub fn verify_pop(&self, registry_did: &str) -> Result<(), AcdpError> {
829 let pre = self.pop_preimage(registry_did)?;
830 self.public_key_jwk
831 .verify(&self.algorithm, &self.pop, &pre)
832 .map_err(|e| KeylessReason::PopInvalid.error(e))
833 }
834}
835
836#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
840pub struct KeyAttestationListItem {
841 pub ctx_id: CtxId,
843 pub content_hash: ContentHash,
845 pub metadata: Value,
848}
849
850#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
852pub struct KeyAttestationList {
853 pub items: Vec<KeyAttestationListItem>,
855 #[serde(default)]
857 pub next_cursor: Option<String>,
858}
859
860#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
864#[serde(deny_unknown_fields)]
865pub struct KeylessCapabilities {
866 pub accepted_oidc_issuers: Vec<String>,
868 pub accepted_spiffe_trust_domains: Vec<String>,
870 pub max_attestation_ttl_seconds: u32,
872}
873
874impl crate::capabilities::CapabilitiesDocument {
875 pub fn keyless(&self) -> Result<Option<KeylessCapabilities>, AcdpError> {
877 let Some(raw) = self.extensions.get("keyless") else {
878 return Ok(None);
879 };
880 let caps = KeylessCapabilities::deserialize(raw)
881 .map_err(|e| AcdpError::SchemaViolation(format!("capabilities.keyless: {e}")))?;
882 if !(60..=3600).contains(&caps.max_attestation_ttl_seconds) {
883 return Err(AcdpError::SchemaViolation(
884 "capabilities.keyless.max_attestation_ttl_seconds must be 60 to 3600".into(),
885 ));
886 }
887 Ok(Some(caps))
888 }
889}
890
891#[derive(Debug, Clone, PartialEq, Eq)]
895pub enum KeylessSigner {
896 Controller,
898 Keyless {
900 identity: WorkloadIdentity,
902 org_wide: bool,
904 },
905}
906
907pub fn may_supersede(
911 predecessor: &KeylessSigner,
912 successor: &KeylessSigner,
913) -> Result<(), KeylessReason> {
914 match (predecessor, successor) {
915 (_, KeylessSigner::Controller) => Ok(()),
916 (_, KeylessSigner::Keyless { org_wide: true, .. }) => Ok(()),
917 (
918 KeylessSigner::Keyless { identity: p, .. },
919 KeylessSigner::Keyless { identity: s, .. },
920 ) if p == s => Ok(()),
921 _ => Err(KeylessReason::KeylessIdentityMismatch),
922 }
923}
924
925#[derive(Debug, Clone, PartialEq, Eq)]
930pub struct ReceiptFacts {
931 pub verified: bool,
933 pub registry_is_attester: bool,
935 pub fingerprint_matches: bool,
937 pub created_at: DateTime<Utc>,
939}
940
941#[derive(Debug, Clone, PartialEq, Eq)]
943pub struct KeylessFacts {
944 pub attestation_valid: bool,
946 pub attester_accepted: bool,
948 pub bindings_ok: bool,
950 pub receipt: Option<ReceiptFacts>,
952 pub not_before: DateTime<Utc>,
954 pub not_after: DateTime<Utc>,
956 pub signature_valid: bool,
958 pub retracted: bool,
960 pub compromised_since: Vec<DateTime<Utc>>,
963}
964
965#[derive(Debug, Clone, Copy, PartialEq, Eq)]
967pub enum IdentityNow {
968 Listed,
970 NotListed,
972 Unreachable,
974}
975
976#[derive(Debug, Clone, Copy, PartialEq, Eq)]
979pub enum KeylessStatus {
980 CurrentlyAuthorized,
982 HistoricallyAuthorized,
984 HistoricallyAuthorizedPreCompromise,
987}
988
989pub fn decide_until_identity(f: &KeylessFacts) -> Result<Option<KeylessStatus>, KeylessReason> {
993 if !f.attestation_valid {
994 return Err(KeylessReason::AttestationInvalid);
995 }
996 if !f.attester_accepted {
997 return Err(KeylessReason::UntrustedAttester);
998 }
999 if !f.bindings_ok {
1000 return Err(KeylessReason::AttestationMismatch);
1001 }
1002 let r = match &f.receipt {
1003 Some(r) if r.verified && r.registry_is_attester && r.fingerprint_matches => r,
1004 _ => return Err(KeylessReason::Unplaceable),
1005 };
1006 if !(f.not_before <= r.created_at && r.created_at < f.not_after) {
1007 return Err(KeylessReason::OutsideAttestationWindow);
1008 }
1009 if !f.signature_valid {
1010 return Err(KeylessReason::InvalidSignature);
1011 }
1012 if f.retracted {
1013 return Err(KeylessReason::AttestationWithdrawn);
1014 }
1015 if let Some(t) = f.compromised_since.iter().min() {
1016 if r.created_at >= *t {
1017 return Err(KeylessReason::Revoked);
1018 }
1019 return Ok(Some(KeylessStatus::HistoricallyAuthorizedPreCompromise));
1020 }
1021 Ok(None)
1022}
1023
1024pub fn classify(
1027 f: &KeylessFacts,
1028 identity_now: IdentityNow,
1029) -> Result<KeylessStatus, KeylessReason> {
1030 if let Some(status) = decide_until_identity(f)? {
1031 return Ok(status);
1032 }
1033 match identity_now {
1034 IdentityNow::Listed => Ok(KeylessStatus::CurrentlyAuthorized),
1035 IdentityNow::NotListed => Ok(KeylessStatus::HistoricallyAuthorized),
1036 IdentityNow::Unreachable => Err(KeylessReason::ControllerUnreachable),
1037 }
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042 use super::*;
1043
1044 const EPH_PUB: &str = "34b4d9043156cb6dcf0beb0a2949b7559c940d2bcb6dbe8c53a9b30278e3a746";
1045
1046 fn eph() -> [u8; 32] {
1047 hex::decode(EPH_PUB).unwrap().try_into().unwrap()
1048 }
1049
1050 #[test]
1051 fn jwk_fingerprint_thumbprint_and_key_id_match_kl_001() {
1052 let jwk = Jwk::from_ed25519(&eph());
1053 assert_eq!(jwk.x, "NLTZBDFWy23PC-sKKUm3VZyUDSvLbb6MU6mzAnjjp0Y");
1054 assert_eq!(
1055 jwk.fingerprint().unwrap(),
1056 "sha256:f7b7676c94df7e8fd9998e38f9fff04d8588b00d1801764e3f1877a3feae4477"
1057 );
1058 assert_eq!(
1059 jwk.thumbprint().unwrap(),
1060 "Gec-ct57Gwltw072qu-aed20Ju93h-m8Qv8yjNgvMOM"
1061 );
1062 let id =
1063 keyless_key_id("did:web:agents.example.com:ci", &jwk.fingerprint().unwrap()).unwrap();
1064 assert!(is_keyless_key_id(&id));
1065 assert!(!is_keyless_key_id("did:web:agents.example.com:ci#key-1"));
1066 }
1067
1068 #[test]
1069 fn identity_entries_and_services() {
1070 let doc: DidDocument = serde_json::from_value(serde_json::json!({
1071 "id": "did:web:a.example",
1072 "service": [{
1073 "id": "did:web:a.example#acdp-keyless",
1074 "type": "AcdpKeylessSigning",
1075 "serviceEndpoint": {"identities": [
1076 {"type": "oidc", "issuer": "https://issuer.example", "subject": "repo:x"},
1077 {"type": "spiffe", "subject": "spiffe://prod.example/sa/p", "org_wide": true},
1078 {"type": "x509", "subject": "ignored"},
1079 {"type": "oidc", "subject": "missing issuer"}
1080 ]}
1081 }]
1082 }))
1083 .unwrap();
1084 let listed = ListedIdentities::of(&doc);
1085 assert_eq!(listed.entries().len(), 2);
1086 let spiffe = WorkloadIdentity::Spiffe {
1087 subject: "spiffe://prod.example/sa/p".into(),
1088 };
1089 assert!(listed.find(&spiffe).unwrap().org_wide);
1090 let upper = WorkloadIdentity::Oidc {
1092 issuer: "https://issuer.example".into(),
1093 subject: "REPO:x".into(),
1094 };
1095 assert!(listed.find(&upper).is_none());
1096
1097 let mut two = doc.clone();
1099 two.services.push(two.services[0].clone());
1100 assert!(ListedIdentities::of(&two).entries().is_empty());
1101 }
1102
1103 #[test]
1104 fn descriptor_debug_redacts_the_token() {
1105 let d = IdentityDescriptor::oidc("https://i.example", "s", "aaa.bbb.ccc");
1106 assert!(!format!("{d:?}").contains("aaa.bbb.ccc"));
1107 let x509 = IdentityDescriptor::Spiffe {
1108 subject: "spiffe://prod.example/sa/p".into(),
1109 svid_type: "x509-svid".into(),
1110 proof: "aaa.bbb.ccc".into(),
1111 };
1112 assert!(x509.validate().is_err());
1113 }
1114
1115 #[test]
1116 fn presence_rule() {
1117 assert!(check_key_attestation_presence("did:web:a#key-1", true).is_err());
1118 assert!(check_key_attestation_presence("did:web:a#key-1", false).is_ok());
1119 assert!(check_key_attestation_presence("did:web:a#acdp-kl-00", true).is_ok());
1120 assert!(check_key_attestation_presence("did:web:a#acdp-kl-00", false).is_ok());
1122 }
1123}