Skip to main content

acdp_types/
keyless.rs

1//! Keyless signing — RFC-ACDP-0021 (0.5.0, Draft).
2//!
3//! A workload signs bodies with an ephemeral key that a registry has
4//! attested for the producer DID (the *controller*) after checking the
5//! workload's identity token. This module holds the offline, pure part:
6//!
7//! - the wire shapes: the `acdp-key-attestation/1` metadata
8//!   ([`KeyAttestation`]), the `POST /key-attestations` request
9//!   ([`KeyAttestationRequest`]), the body's `key_attestation` member
10//!   ([`KeyAttestationRef`]) and the capabilities `keyless` member
11//!   ([`KeylessCapabilities`]);
12//! - the derived values: [`keyless_key_id`], [`token_hash`], the JWK
13//!   fingerprint and thumbprint ([`Jwk`]), and the proof-of-possession
14//!   preimage ([`pop_preimage`]);
15//! - the controller's `AcdpKeylessSigning` service ([`ListedIdentities`]);
16//! - the two decision tables: who may supersede whom (§7,
17//!   [`may_supersede`]) and the key status of a keyless signature
18//!   (§8.2, [`classify`]).
19//!
20//! Fetching attestations and verifying bodies against them needs the
21//! network or a bundle and lives in `acdp_client` and `acdp_chain`
22//! (feature `keyless`). Nothing here adds a key-authorization state or a
23//! revocation trust class: [`KeylessStatus`] has exactly the three
24//! statuses consumers already report.
25
26use 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
43/// The body `type` of a key attestation (§5.3).
44pub const KEY_ATTESTATION_TYPE: &str = "acdp:key-attestation";
45/// The `schema_uri` of a key attestation (§5.3).
46pub const KEY_ATTESTATION_SCHEMA_URI: &str =
47    "https://schemas.acdp.io/v0.1.0/acdp-key-attestation.schema.json";
48/// `attestation_version` (§5.3).
49pub const ATTESTATION_VERSION: &str = "acdp-key-attestation/1";
50/// `request_version` (§5.1).
51pub const REQUEST_VERSION: &str = "acdp-key-attestation-request/1";
52/// `pop_version` of the proof-of-possession preimage (§5.1).
53pub const POP_VERSION: &str = "acdp-key-attestation-pop/1";
54/// The DID-document service type that lists authorized identities (§4.1).
55pub const KEYLESS_SERVICE_TYPE: &str = "AcdpKeylessSigning";
56/// The longest attestation window, in seconds (§5.2 step 7).
57pub const MAX_WINDOW_SECONDS: i64 = 3600;
58/// The window a registry mints when the request names none (§5.1).
59pub const DEFAULT_TTL_SECONDS: u32 = 600;
60/// The registry profile (§13.2).
61pub const REGISTRY_PROFILE: &str = "acdp-registry-keyless";
62/// The consumer profile (§13.2).
63pub 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
69// ── §6.1 key ids and §5.1 hashes ───────────────────────────────────────────
70
71/// True when `key_id`'s fragment begins `acdp-kl-` (§2, §6.1): the key
72/// is resolved through a key attestation, never through RFC-ACDP-0001
73/// §5.11.
74pub 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
80/// `<controller>#acdp-kl-<hex>` for an RFC-ACDP-0010 §6 fingerprint
81/// (§6.1).
82pub 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
90/// `sha256:` + hex SHA-256 of the token's ASCII bytes (§5.1).
91pub 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
112/// True when `v` is `major.minor.patch` at or above 0.5.0.
113fn 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// ── JWK (RFC 7517 / RFC 7638) ──────────────────────────────────────────────
119
120/// An attested public key as a JWK with exactly the RFC 7638 members:
121/// `{crv, kty, x}` for Ed25519, `{crv, kty, x, y}` for P-256.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(deny_unknown_fields)]
124pub struct Jwk {
125    /// `Ed25519` or `P-256`.
126    pub crv: String,
127    /// `OKP` or `EC`.
128    pub kty: String,
129    /// base64url (no padding) of the key, or of the P-256 x coordinate.
130    pub x: String,
131    /// base64url (no padding) of the P-256 y coordinate.
132    #[serde(
133        default,
134        deserialize_with = "de_present",
135        skip_serializing_if = "Option::is_none"
136    )]
137    pub y: Option<String>,
138}
139
140/// A JWK decoded to key bytes.
141enum KeyBytes {
142    Ed25519([u8; 32]),
143    /// SEC1 uncompressed: `0x04 || x || y`.
144    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    /// The JWK of a raw Ed25519 public key.
162    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    /// The JWK of a SEC1 P-256 public key (compressed or uncompressed).
172    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    /// The signature algorithm the key type implies (§5.2 step 5).
203    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    /// The RFC-ACDP-0010 §6 fingerprint: over the raw 32-byte Ed25519
211    /// key, or the 33-byte SEC1-compressed P-256 point (§5.3).
212    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    /// The RFC 7638 thumbprint: base64url SHA-256 of the JCS bytes of
220    /// the required members (§5.2 step 3, `cnf.jkt`).
221    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    /// Verify a base64 signature under `algorithm` over `message`'s
228    /// bytes. `algorithm` must be the one the key type implies.
229    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// ── §4 workload identities ─────────────────────────────────────────────────
250
251/// A workload identity: the RFC-AITP-0002 identity hint, with no proof
252/// (§2, §4.1, §5.3). Equality is the §4.1 byte-for-byte comparison.
253#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
254#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
255pub enum WorkloadIdentity {
256    /// An OIDC issuer and `sub`.
257    Oidc {
258        /// The issuer URI (`https://`).
259        issuer: String,
260        /// The token's `sub`.
261        subject: String,
262    },
263    /// A SPIFFE ID.
264    Spiffe {
265        /// The SPIFFE ID (`spiffe://<trust domain>/...`).
266        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
286/// The SPIFFE trust domain of a SPIFFE ID, if it is one.
287pub 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    /// Check the schema patterns.
311    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/// An RFC-AITP-0002 identity descriptor with its token as `proof`
320/// (§5.1). `Debug` never prints the token.
321#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
323pub enum IdentityDescriptor {
324    /// An OIDC ID token.
325    Oidc {
326        /// The issuer URI.
327        issuer: String,
328        /// The token's `sub`.
329        subject: String,
330        /// The ID token.
331        proof: String,
332    },
333    /// A SPIFFE JWT-SVID.
334    Spiffe {
335        /// The SPIFFE ID.
336        subject: String,
337        /// Always `jwt-svid`: X509-SVIDs are not accepted.
338        svid_type: String,
339        /// The JWT-SVID.
340        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    /// An OIDC descriptor.
380    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    /// A SPIFFE JWT-SVID descriptor.
393    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    /// The token.
402    pub fn token(&self) -> &str {
403        match self {
404            Self::Oidc { proof, .. } | Self::Spiffe { proof, .. } => proof,
405        }
406    }
407
408    /// The identity without its proof.
409    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    /// Check the schema patterns: X509-SVIDs and malformed tokens are a
424    /// `schema_violation`.
425    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/// One entry of the controller's `AcdpKeylessSigning` service (§4.1).
458#[derive(Debug, Clone, PartialEq, Eq)]
459pub struct ListedIdentity {
460    /// The identity.
461    pub identity: WorkloadIdentity,
462    /// Whether its keys may supersede any of the controller's lineages.
463    pub org_wide: bool,
464}
465
466/// The identities a controller's DID document authorizes (§4.1).
467#[derive(Debug, Clone, Default, PartialEq, Eq)]
468pub struct ListedIdentities(Vec<ListedIdentity>);
469
470impl ListedIdentities {
471    /// Read the document's `AcdpKeylessSigning` service. None, or more
472    /// than one, authorizes no identity. Entries of a type other than
473    /// `oidc` or `spiffe`, and malformed entries, are ignored.
474    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    /// The entry listing `identity`, compared byte for byte.
493    pub fn find(&self, identity: &WorkloadIdentity) -> Option<&ListedIdentity> {
494        self.0.iter().find(|e| &e.identity == identity)
495    }
496
497    /// Every listed entry.
498    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// ── §5.3 the attestation ───────────────────────────────────────────────────
517
518/// The `acdp-key-attestation/1` metadata of an `acdp:key-attestation`
519/// body (§5.3). Closed at every level; build it with
520/// [`KeyAttestation::from_metadata`] or [`KeyAttestation::from_body`],
521/// which also check the rules the schema cannot express.
522#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
523#[serde(deny_unknown_fields)]
524pub struct KeyAttestation {
525    /// Always [`ATTESTATION_VERSION`].
526    pub attestation_version: String,
527    /// The controller's `did:web` DID.
528    pub controller: String,
529    /// The verified workload identity.
530    pub identity: WorkloadIdentity,
531    /// Copied from the controller's DID-document entry.
532    pub org_wide: bool,
533    /// `<controller>#acdp-kl-<hex>` (§6.1).
534    pub key_id: String,
535    /// `ed25519` or `ecdsa-p256`.
536    pub algorithm: String,
537    /// The attested key.
538    pub public_key_jwk: Jwk,
539    /// The attested key's RFC-ACDP-0010 §6 fingerprint.
540    pub key_fingerprint: String,
541    /// Start of the window, inclusive.
542    #[serde(with = "crate::trust_list::ms")]
543    pub not_before: DateTime<Utc>,
544    /// End of the window, exclusive.
545    #[serde(with = "crate::trust_list::ms")]
546    pub not_after: DateTime<Utc>,
547    /// `sha256:` + hex SHA-256 of the token.
548    pub token_hash: String,
549}
550
551impl KeyAttestation {
552    /// Parse the metadata object and check §5.3: the closed schema, the
553    /// fingerprint of `public_key_jwk`, the `key_id` derived from it, the
554    /// algorithm matching the key type, and the window bound.
555    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    /// Check an attestation body against §5.3 and return its metadata:
606    /// the body shape (type, `public`, version 1, no lineage or links),
607    /// `agent_id` equal to `did:web:` plus the `ctx_id` authority, the
608    /// `schema_uri`, `acdp_version` ≥ 0.5.0 and the metadata rules.
609    ///
610    /// The body's own hash, signature and receipt are the caller's to
611    /// verify (§8.1 step 1).
612    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    /// §6.2 / §8.1 step 3: `agent_id`, `signature.key_id` and
652    /// `signature.algorithm` match this attestation.
653    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    /// True when `t` lies in `[not_before, not_after)`.
660    pub fn window_contains(&self, t: DateTime<Utc>) -> bool {
661        self.not_before <= t && t < self.not_after
662    }
663
664    /// The signer this attestation makes of a body (§7).
665    pub fn signer(&self) -> KeylessSigner {
666        KeylessSigner::Keyless {
667            identity: self.identity.clone(),
668            org_wide: self.org_wide,
669        }
670    }
671}
672
673// ── §6.2 the body's key_attestation member ─────────────────────────────────
674
675/// A keyless-signed body's `key_attestation` member (§6.2): names the
676/// attestation and pins its bytes. Producer-signed (inside the
677/// `content_hash`).
678#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
679#[serde(deny_unknown_fields)]
680pub struct KeyAttestationRef {
681    /// The attestation's `ctx_id`.
682    pub ctx_id: CtxId,
683    /// The attestation's `content_hash`.
684    pub content_hash: ContentHash,
685}
686
687impl KeyAttestationRef {
688    /// Check the member's identifier patterns.
689    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
696/// The `key_attestation` member name.
697pub const KEY_ATTESTATION_FIELD: &str = "key_attestation";
698
699impl Body {
700    /// The `key_attestation` member (§6.2), read from the forward-
701    /// compatible extension fields where a stored body carries it.
702    /// `Ok(None)` when absent; an error when present but malformed.
703    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
714/// §6.2: `key_attestation` is present exactly when the signature's key
715/// id is keyless. A keyless key id with no member is left to key
716/// resolution (`attestation_missing`, §6.3 step 1); the member on a
717/// non-keyless key id is a `schema_violation` (kl-003 I).
718pub 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
728// ── §5.1 the request and its proof of possession ───────────────────────────
729
730/// The JCS bytes of the `acdp-key-attestation-pop/1` preimage (§5.1),
731/// which the ephemeral key signs as `pop`.
732pub 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/// The body of `POST /key-attestations` (§5.1). Closed. `Debug` never
750/// prints the token.
751#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
752#[serde(deny_unknown_fields)]
753pub struct KeyAttestationRequest {
754    /// Always [`REQUEST_VERSION`].
755    pub request_version: String,
756    /// The controller's `did:web` DID.
757    pub controller: String,
758    /// The identity descriptor with the token as `proof`.
759    pub identity: IdentityDescriptor,
760    /// `ed25519` or `ecdsa-p256`.
761    pub algorithm: String,
762    /// The ephemeral public key.
763    pub public_key_jwk: Jwk,
764    /// Base64 signature by the ephemeral key over [`pop_preimage`].
765    pub pop: String,
766    /// The window length asked for, 1 to 3600 seconds (default 600).
767    #[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    /// §5.2 step 1: the closed schema plus its patterns. A `did:key`
777    /// controller and an X509-SVID are `schema_violation`.
778    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    /// `sha256:` + hex SHA-256 of the token.
813    pub fn token_hash(&self) -> String {
814        token_hash(self.identity.token())
815    }
816
817    /// The preimage `pop` signs, for the registry `registry_did`.
818    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    /// §5.2 step 4: `pop` verifies under `public_key_jwk`.
828    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// ── §5.4 listing ───────────────────────────────────────────────────────────
837
838/// One item of `GET /key-attestations` (§5.4).
839#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
840pub struct KeyAttestationListItem {
841    /// The attestation's `ctx_id`.
842    pub ctx_id: CtxId,
843    /// The attestation's `content_hash`.
844    pub content_hash: ContentHash,
845    /// Its `acdp-key-attestation/1` metadata, as served: parse it with
846    /// [`KeyAttestation::from_metadata`].
847    pub metadata: Value,
848}
849
850/// A page of `GET /key-attestations` (§5.4), newest first.
851#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
852pub struct KeyAttestationList {
853    /// The attestations on this page.
854    pub items: Vec<KeyAttestationListItem>,
855    /// The cursor of the next page, absent (or null) on the last.
856    #[serde(default)]
857    pub next_cursor: Option<String>,
858}
859
860// ── §13.1 capabilities ─────────────────────────────────────────────────────
861
862/// The capabilities document's `keyless` member (§13.1).
863#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
864#[serde(deny_unknown_fields)]
865pub struct KeylessCapabilities {
866    /// OIDC issuers whose tokens the registry verifies.
867    pub accepted_oidc_issuers: Vec<String>,
868    /// SPIFFE trust domains whose JWT-SVIDs the registry verifies.
869    pub accepted_spiffe_trust_domains: Vec<String>,
870    /// The longest window the registry mints, 60 to 3600.
871    pub max_attestation_ttl_seconds: u32,
872}
873
874impl crate::capabilities::CapabilitiesDocument {
875    /// The `keyless` member, when the registry advertises one.
876    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// ── §7 supersession ────────────────────────────────────────────────────────
892
893/// How a body is signed, for §7.
894#[derive(Debug, Clone, PartialEq, Eq)]
895pub enum KeylessSigner {
896    /// A key in the controller's DID document.
897    Controller,
898    /// A keyless key, with its attestation's identity and `org_wide`.
899    Keyless {
900        /// The attested identity.
901        identity: WorkloadIdentity,
902        /// The attestation's `org_wide`.
903        org_wide: bool,
904    },
905}
906
907/// §7: may a body signed by `successor` supersede (or carry a producer
908/// lifecycle event on) one signed by `predecessor` under the same
909/// `agent_id`? Refused with `keyless_identity_mismatch` otherwise.
910pub 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// ── §8 the key status of a keyless signature ───────────────────────────────
926
927/// The registry receipt that places a keyless signature, as §8.1 step 4
928/// sees it.
929#[derive(Debug, Clone, PartialEq, Eq)]
930pub struct ReceiptFacts {
931    /// The receipt verifies (RFC-ACDP-0010 §8).
932    pub verified: bool,
933    /// It was issued by the attesting registry.
934    pub registry_is_attester: bool,
935    /// Its `key_fingerprint` is the attested one.
936    pub fingerprint_matches: bool,
937    /// Its `created_at`: the only time that places the signature.
938    pub created_at: DateTime<Utc>,
939}
940
941/// What §8.1 steps 1–8 found, in the form the decision runs on.
942#[derive(Debug, Clone, PartialEq, Eq)]
943pub struct KeylessFacts {
944    /// Step 1: the attestation verifies and meets §5.3.
945    pub attestation_valid: bool,
946    /// Step 2: the consumer accepts the attesting registry (§11).
947    pub attester_accepted: bool,
948    /// Step 3: `agent_id`, `key_id` and `algorithm` match.
949    pub bindings_ok: bool,
950    /// Step 4: the body's receipt, if any.
951    pub receipt: Option<ReceiptFacts>,
952    /// The attestation window's start (inclusive).
953    pub not_before: DateTime<Utc>,
954    /// The attestation window's end (exclusive).
955    pub not_after: DateTime<Utc>,
956    /// Step 6: the signature verifies under the attested key.
957    pub signature_valid: bool,
958    /// Step 7: the attesting registry retracted the attestation.
959    pub retracted: bool,
960    /// Step 8: `compromised_since` of every revocation that applies to
961    /// the attested fingerprint.
962    pub compromised_since: Vec<DateTime<Utc>>,
963}
964
965/// §8.1 step 9: the controller's current document.
966#[derive(Debug, Clone, Copy, PartialEq, Eq)]
967pub enum IdentityNow {
968    /// It lists the identity.
969    Listed,
970    /// It does not.
971    NotListed,
972    /// It could not be fetched.
973    Unreachable,
974}
975
976/// The three key statuses (§8.2). A keyless key ends in one of them or
977/// fails closed; there is no fourth.
978#[derive(Debug, Clone, Copy, PartialEq, Eq)]
979pub enum KeylessStatus {
980    /// The controller still lists the identity.
981    CurrentlyAuthorized,
982    /// Receipt-attested: the controller has since removed the identity.
983    HistoricallyAuthorized,
984    /// Pre-compromise, receipt-attested: a revocation names the key and
985    /// the receipt places the signature before `compromised_since`.
986    HistoricallyAuthorizedPreCompromise,
987}
988
989/// §8.1 steps 1–8 in their fixed order; the first failure decides.
990/// `Ok(Some(_))` is the pre-compromise status (step 8 needs no current
991/// document); `Ok(None)` means step 9 decides.
992pub 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
1024/// §8.1 and §8.2: the key status of a keyless signature, or the reason
1025/// it fails closed.
1026pub 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        // Byte-for-byte: no case folding.
1091        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        // Two services authorize nothing.
1098        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        // A keyless key id without the member is left to key resolution.
1121        assert!(check_key_attestation_presence("did:web:a#acdp-kl-00", false).is_ok());
1122    }
1123}