use acdp_crypto::fingerprint::{fingerprint_ed25519, fingerprint_p256_sec1};
use acdp_did::DidDocument;
use acdp_primitives::error::AcdpError;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use base64::Engine;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use crate::body::{Body, Signature};
use crate::primitives::{AgentDid, ContentHash, CtxId};
use crate::serde_helpers::de_present;
pub use acdp_did::KEYLESS_FRAGMENT_PREFIX;
pub use acdp_primitives::keyless::KeylessReason;
pub const KEY_ATTESTATION_TYPE: &str = "acdp:key-attestation";
pub const KEY_ATTESTATION_SCHEMA_URI: &str =
"https://schemas.acdp.io/v0.1.0/acdp-key-attestation.schema.json";
pub const ATTESTATION_VERSION: &str = "acdp-key-attestation/1";
pub const REQUEST_VERSION: &str = "acdp-key-attestation-request/1";
pub const POP_VERSION: &str = "acdp-key-attestation-pop/1";
pub const KEYLESS_SERVICE_TYPE: &str = "AcdpKeylessSigning";
pub const MAX_WINDOW_SECONDS: i64 = 3600;
pub const DEFAULT_TTL_SECONDS: u32 = 600;
pub const REGISTRY_PROFILE: &str = "acdp-registry-keyless";
pub const CONSUMER_PROFILE: &str = "acdp-consumer-keyless";
fn invalid(msg: impl std::fmt::Display) -> AcdpError {
AcdpError::SchemaViolation(format!("key attestation: {msg}"))
}
pub fn is_keyless_key_id(key_id: &str) -> bool {
key_id
.split_once('#')
.is_some_and(|(_, frag)| frag.starts_with(KEYLESS_FRAGMENT_PREFIX))
}
pub fn keyless_key_id(controller: &str, key_fingerprint: &str) -> Result<String, AcdpError> {
let hex = key_fingerprint
.strip_prefix("sha256:")
.filter(|h| is_lower_hex_64(h))
.ok_or_else(|| invalid(format!("'{key_fingerprint}' is not a sha256 fingerprint")))?;
Ok(format!("{controller}#{KEYLESS_FRAGMENT_PREFIX}{hex}"))
}
pub fn token_hash(token: &str) -> String {
format!("sha256:{}", hex::encode(Sha256::digest(token.as_bytes())))
}
fn is_lower_hex_64(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
fn is_sha256(s: &str) -> bool {
s.strip_prefix("sha256:").is_some_and(is_lower_hex_64)
}
fn is_did_web(s: &str) -> bool {
s.strip_prefix("did:web:").is_some_and(|rest| {
!rest.is_empty()
&& rest
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._:%-".contains(&b))
})
}
fn at_least_0_5(v: &str) -> bool {
let parts: Vec<u64> = v.split('.').filter_map(|p| p.parse().ok()).collect();
parts.len() == 3 && (parts[0], parts[1]) >= (0, 5)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Jwk {
pub crv: String,
pub kty: String,
pub x: String,
#[serde(
default,
deserialize_with = "de_present",
skip_serializing_if = "Option::is_none"
)]
pub y: Option<String>,
}
enum KeyBytes {
Ed25519([u8; 32]),
P256(Vec<u8>),
}
fn b64u_32(field: &str, s: &str) -> Result<[u8; 32], AcdpError> {
if s.len() != 43 {
return Err(invalid(format!(
"public_key_jwk.{field} is not 32 base64url bytes"
)));
}
URL_SAFE_NO_PAD
.decode(s)
.ok()
.and_then(|b| <[u8; 32]>::try_from(b).ok())
.ok_or_else(|| invalid(format!("public_key_jwk.{field} is not 32 base64url bytes")))
}
impl Jwk {
pub fn from_ed25519(public_key: &[u8; 32]) -> Self {
Jwk {
crv: "Ed25519".into(),
kty: "OKP".into(),
x: URL_SAFE_NO_PAD.encode(public_key),
y: None,
}
}
pub fn from_p256_sec1(sec1: &[u8]) -> Result<Self, AcdpError> {
let vk = p256::ecdsa::VerifyingKey::from_sec1_bytes(sec1)
.map_err(|e| invalid(format!("P-256 key: {e}")))?;
let point = vk.to_sec1_point(false);
let raw = point.as_bytes();
Ok(Jwk {
crv: "P-256".into(),
kty: "EC".into(),
x: URL_SAFE_NO_PAD.encode(&raw[1..33]),
y: Some(URL_SAFE_NO_PAD.encode(&raw[33..65])),
})
}
fn key_bytes(&self) -> Result<KeyBytes, AcdpError> {
match (self.kty.as_str(), self.crv.as_str(), &self.y) {
("OKP", "Ed25519", None) => Ok(KeyBytes::Ed25519(b64u_32("x", &self.x)?)),
("EC", "P-256", Some(y)) => {
let mut sec1 = vec![0x04];
sec1.extend_from_slice(&b64u_32("x", &self.x)?);
sec1.extend_from_slice(&b64u_32("y", y)?);
p256::ecdsa::VerifyingKey::from_sec1_bytes(&sec1)
.map_err(|e| invalid(format!("public_key_jwk is not a P-256 point: {e}")))?;
Ok(KeyBytes::P256(sec1))
}
_ => Err(invalid(
"public_key_jwk must be {kty: OKP, crv: Ed25519, x} or {kty: EC, crv: P-256, x, y}",
)),
}
}
pub fn algorithm(&self) -> Result<&'static str, AcdpError> {
Ok(match self.key_bytes()? {
KeyBytes::Ed25519(_) => "ed25519",
KeyBytes::P256(_) => "ecdsa-p256",
})
}
pub fn fingerprint(&self) -> Result<String, AcdpError> {
match self.key_bytes()? {
KeyBytes::Ed25519(k) => Ok(fingerprint_ed25519(&k)),
KeyBytes::P256(sec1) => fingerprint_p256_sec1(&sec1),
}
}
pub fn thumbprint(&self) -> Result<String, AcdpError> {
self.key_bytes()?;
let canon = acdp_jcs::canonicalize(self)?;
Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(canon)))
}
pub fn verify(
&self,
algorithm: &str,
signature_b64: &str,
message: &str,
) -> Result<(), AcdpError> {
match (self.key_bytes()?, algorithm) {
(KeyBytes::Ed25519(k), "ed25519") => {
acdp_crypto::verify_ed25519(&k, signature_b64, message)
}
(KeyBytes::P256(sec1), "ecdsa-p256") => {
acdp_crypto::verify_ecdsa_p256(&sec1, signature_b64, message)
}
_ => Err(AcdpError::InvalidSignature(format!(
"signature algorithm '{algorithm}' does not match the attested key type"
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
pub enum WorkloadIdentity {
Oidc {
issuer: String,
subject: String,
},
Spiffe {
subject: String,
},
}
fn check_oidc(issuer: &str, subject: &str) -> Result<(), AcdpError> {
let issuer_ok = issuer.len() <= 512
&& issuer.strip_prefix("https://").is_some_and(|r| {
!r.is_empty() && !r.chars().any(|c| c.is_whitespace() || c == '#' || c == '?')
});
if !issuer_ok {
return Err(invalid(format!(
"identity.issuer '{issuer}' is not an https issuer URI"
)));
}
if subject.is_empty() || subject.len() > 512 {
return Err(invalid("identity.subject must be 1 to 512 characters"));
}
Ok(())
}
pub fn spiffe_trust_domain(subject: &str) -> Option<&str> {
let rest = subject.strip_prefix("spiffe://")?;
let domain = rest.split('/').next()?;
let domain_ok = !domain.is_empty()
&& domain
.bytes()
.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'-'));
let path_ok = rest[domain.len()..].split('/').skip(1).all(|seg| {
!seg.is_empty()
&& seg
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._~%!$&'()*+,;=:@-".contains(&b))
});
(domain_ok && path_ok && subject.len() <= 2048).then_some(domain)
}
fn check_spiffe(subject: &str) -> Result<(), AcdpError> {
spiffe_trust_domain(subject)
.map(|_| ())
.ok_or_else(|| invalid(format!("identity.subject '{subject}' is not a SPIFFE ID")))
}
impl WorkloadIdentity {
pub fn validate(&self) -> Result<(), AcdpError> {
match self {
Self::Oidc { issuer, subject } => check_oidc(issuer, subject),
Self::Spiffe { subject } => check_spiffe(subject),
}
}
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase", deny_unknown_fields)]
pub enum IdentityDescriptor {
Oidc {
issuer: String,
subject: String,
proof: String,
},
Spiffe {
subject: String,
svid_type: String,
proof: String,
},
}
impl std::fmt::Debug for IdentityDescriptor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Oidc {
issuer, subject, ..
} => f
.debug_struct("Oidc")
.field("issuer", issuer)
.field("subject", subject)
.field("proof", &"<redacted>")
.finish(),
Self::Spiffe {
subject, svid_type, ..
} => f
.debug_struct("Spiffe")
.field("subject", subject)
.field("svid_type", svid_type)
.field("proof", &"<redacted>")
.finish(),
}
}
}
fn is_compact_jwt(s: &str) -> bool {
let parts: Vec<&str> = s.split('.').collect();
s.len() <= 16384
&& parts.len() == 3
&& parts.iter().all(|p| {
!p.is_empty()
&& p.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
})
}
impl IdentityDescriptor {
pub fn oidc(
issuer: impl Into<String>,
subject: impl Into<String>,
token: impl Into<String>,
) -> Self {
Self::Oidc {
issuer: issuer.into(),
subject: subject.into(),
proof: token.into(),
}
}
pub fn spiffe_jwt(subject: impl Into<String>, svid: impl Into<String>) -> Self {
Self::Spiffe {
subject: subject.into(),
svid_type: "jwt-svid".into(),
proof: svid.into(),
}
}
pub fn token(&self) -> &str {
match self {
Self::Oidc { proof, .. } | Self::Spiffe { proof, .. } => proof,
}
}
pub fn identity(&self) -> WorkloadIdentity {
match self {
Self::Oidc {
issuer, subject, ..
} => WorkloadIdentity::Oidc {
issuer: issuer.clone(),
subject: subject.clone(),
},
Self::Spiffe { subject, .. } => WorkloadIdentity::Spiffe {
subject: subject.clone(),
},
}
}
pub fn validate(&self) -> Result<(), AcdpError> {
match self {
Self::Oidc {
issuer,
subject,
proof,
} => {
check_oidc(issuer, subject)?;
if !is_compact_jwt(proof) {
return Err(invalid("identity.proof is not a compact JWT"));
}
}
Self::Spiffe {
subject,
svid_type,
proof,
} => {
check_spiffe(subject)?;
if svid_type != "jwt-svid" {
return Err(invalid(format!(
"identity.svid_type '{svid_type}' is not jwt-svid (X509-SVIDs are not accepted)"
)));
}
if !is_compact_jwt(proof) {
return Err(invalid("identity.proof is not a compact JWT-SVID"));
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListedIdentity {
pub identity: WorkloadIdentity,
pub org_wide: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ListedIdentities(Vec<ListedIdentity>);
impl ListedIdentities {
pub fn of(doc: &DidDocument) -> Self {
let mut services = doc
.services
.iter()
.filter(|s| s.get("type").and_then(Value::as_str) == Some(KEYLESS_SERVICE_TYPE));
let (Some(service), None) = (services.next(), services.next()) else {
return Self::default();
};
let Some(entries) = service
.get("serviceEndpoint")
.and_then(|e| e.get("identities"))
.and_then(Value::as_array)
else {
return Self::default();
};
Self(entries.iter().filter_map(listed_entry).collect())
}
pub fn find(&self, identity: &WorkloadIdentity) -> Option<&ListedIdentity> {
self.0.iter().find(|e| &e.identity == identity)
}
pub fn entries(&self) -> &[ListedIdentity] {
&self.0
}
}
fn listed_entry(entry: &Value) -> Option<ListedIdentity> {
let obj = entry.as_object()?;
let org_wide = match obj.get("org_wide") {
None => false,
Some(v) => v.as_bool()?,
};
let mut hint = obj.clone();
hint.remove("org_wide");
let identity: WorkloadIdentity = serde_json::from_value(Value::Object(hint)).ok()?;
identity.validate().ok()?;
Some(ListedIdentity { identity, org_wide })
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyAttestation {
pub attestation_version: String,
pub controller: String,
pub identity: WorkloadIdentity,
pub org_wide: bool,
pub key_id: String,
pub algorithm: String,
pub public_key_jwk: Jwk,
pub key_fingerprint: String,
#[serde(with = "crate::trust_list::ms")]
pub not_before: DateTime<Utc>,
#[serde(with = "crate::trust_list::ms")]
pub not_after: DateTime<Utc>,
pub token_hash: String,
}
impl KeyAttestation {
pub fn from_metadata(metadata: &Value) -> Result<Self, AcdpError> {
let att = Self::deserialize(metadata).map_err(invalid)?;
att.validate()?;
Ok(att)
}
fn validate(&self) -> Result<(), AcdpError> {
if self.attestation_version != ATTESTATION_VERSION {
return Err(invalid(format!(
"attestation_version '{}' is not '{ATTESTATION_VERSION}'",
self.attestation_version
)));
}
if !is_did_web(&self.controller) {
return Err(invalid(format!(
"controller '{}' is not a did:web DID",
self.controller
)));
}
self.identity.validate()?;
if self.public_key_jwk.algorithm()? != self.algorithm {
return Err(invalid(format!(
"algorithm '{}' does not match the public_key_jwk key type",
self.algorithm
)));
}
if !is_sha256(&self.key_fingerprint)
|| self.public_key_jwk.fingerprint()? != self.key_fingerprint
{
return Err(invalid(
"key_fingerprint is not the fingerprint of public_key_jwk",
));
}
if self.key_id != keyless_key_id(&self.controller, &self.key_fingerprint)? {
return Err(invalid(
"key_id is not <controller>#acdp-kl-<fingerprint hex>",
));
}
let span = (self.not_after - self.not_before).num_milliseconds();
if span <= 0 || span > MAX_WINDOW_SECONDS * 1000 {
return Err(invalid(
"the window must be non-empty and at most 3600 seconds",
));
}
if !is_sha256(&self.token_hash) {
return Err(invalid("token_hash is not a sha256 digest"));
}
Ok(())
}
pub fn from_body(body: &Body) -> Result<Self, AcdpError> {
if !matches!(&body.context_type, crate::primitives::ContextType::Custom(t) if t == KEY_ATTESTATION_TYPE)
{
return Err(invalid(format!("type is not {KEY_ATTESTATION_TYPE}")));
}
if body.visibility != crate::primitives::Visibility::Public {
return Err(invalid("an attestation must be public"));
}
if body.version != 1
|| body.supersedes.is_some()
|| !body.data_refs.is_empty()
|| !body.derived_from.is_empty()
{
return Err(invalid(
"an attestation is version 1 with no supersedes, data_refs or derived_from",
));
}
let registry = acdp_did::authority_to_did_web(body.ctx_id.authority());
if body.agent_id.as_str() != registry {
return Err(invalid(format!(
"agent_id '{}' is not the attesting registry '{registry}' of its ctx_id",
body.agent_id
)));
}
if body.schema_uri.as_deref() != Some(KEY_ATTESTATION_SCHEMA_URI) {
return Err(invalid(format!(
"schema_uri is not {KEY_ATTESTATION_SCHEMA_URI}"
)));
}
if !body.acdp_version.as_deref().is_some_and(at_least_0_5) {
return Err(invalid("acdp_version must be at least 0.5.0"));
}
let metadata = body
.metadata
.as_ref()
.ok_or_else(|| invalid("metadata is missing"))?;
Self::from_metadata(metadata)
}
pub fn binds(&self, agent_id: &AgentDid, signature: &Signature) -> bool {
agent_id.as_str() == self.controller
&& signature.key_id == self.key_id
&& signature.algorithm == self.algorithm
}
pub fn window_contains(&self, t: DateTime<Utc>) -> bool {
self.not_before <= t && t < self.not_after
}
pub fn signer(&self) -> KeylessSigner {
KeylessSigner::Keyless {
identity: self.identity.clone(),
org_wide: self.org_wide,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyAttestationRef {
pub ctx_id: CtxId,
pub content_hash: ContentHash,
}
impl KeyAttestationRef {
pub fn validate(&self) -> Result<(), AcdpError> {
CtxId::parse(self.ctx_id.as_str())?;
ContentHash::parse(self.content_hash.as_str())?;
Ok(())
}
}
pub const KEY_ATTESTATION_FIELD: &str = "key_attestation";
impl Body {
pub fn key_attestation(&self) -> Result<Option<KeyAttestationRef>, AcdpError> {
let Some(raw) = self.extensions.get(KEY_ATTESTATION_FIELD) else {
return Ok(None);
};
let r = KeyAttestationRef::deserialize(raw)
.map_err(|e| AcdpError::SchemaViolation(format!("key_attestation: {e}")))?;
r.validate()?;
Ok(Some(r))
}
}
pub fn check_key_attestation_presence(key_id: &str, present: bool) -> Result<(), AcdpError> {
if present && !is_keyless_key_id(key_id) {
return Err(AcdpError::SchemaViolation(format!(
"key_attestation is present but signature.key_id '{key_id}' is not a keyless key id \
(RFC-ACDP-0021 §6.2)"
)));
}
Ok(())
}
pub fn pop_preimage(
controller: &str,
registry_did: &str,
token_hash: &str,
key_fingerprint: &str,
) -> Result<String, AcdpError> {
let pre = serde_json::json!({
"pop_version": POP_VERSION,
"controller": controller,
"registry_did": registry_did,
"token_hash": token_hash,
"key_fingerprint": key_fingerprint,
});
String::from_utf8(acdp_jcs::canonicalize(&pre)?)
.map_err(|e| AcdpError::Serialization(e.to_string()))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeyAttestationRequest {
pub request_version: String,
pub controller: String,
pub identity: IdentityDescriptor,
pub algorithm: String,
pub public_key_jwk: Jwk,
pub pop: String,
#[serde(
default,
deserialize_with = "de_present",
skip_serializing_if = "Option::is_none"
)]
pub requested_ttl_seconds: Option<u32>,
}
impl KeyAttestationRequest {
pub fn validate(&self) -> Result<(), AcdpError> {
if self.request_version != REQUEST_VERSION {
return Err(invalid(format!(
"request_version '{}' is not '{REQUEST_VERSION}'",
self.request_version
)));
}
if !is_did_web(&self.controller) {
return Err(invalid(format!(
"controller '{}' is not a did:web DID",
self.controller
)));
}
self.identity.validate()?;
if !matches!(self.algorithm.as_str(), "ed25519" | "ecdsa-p256") {
return Err(invalid(format!(
"algorithm '{}' is not supported",
self.algorithm
)));
}
self.public_key_jwk.algorithm()?;
let pop_ok = !self.pop.is_empty() && STANDARD.decode(&self.pop).is_ok();
if !pop_ok {
return Err(invalid("pop is not base64"));
}
if self
.requested_ttl_seconds
.is_some_and(|t| t == 0 || i64::from(t) > MAX_WINDOW_SECONDS)
{
return Err(invalid("requested_ttl_seconds must be 1 to 3600"));
}
Ok(())
}
pub fn token_hash(&self) -> String {
token_hash(self.identity.token())
}
pub fn pop_preimage(&self, registry_did: &str) -> Result<String, AcdpError> {
pop_preimage(
&self.controller,
registry_did,
&self.token_hash(),
&self.public_key_jwk.fingerprint()?,
)
}
pub fn verify_pop(&self, registry_did: &str) -> Result<(), AcdpError> {
let pre = self.pop_preimage(registry_did)?;
self.public_key_jwk
.verify(&self.algorithm, &self.pop, &pre)
.map_err(|e| KeylessReason::PopInvalid.error(e))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KeyAttestationListItem {
pub ctx_id: CtxId,
pub content_hash: ContentHash,
pub metadata: Value,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KeyAttestationList {
pub items: Vec<KeyAttestationListItem>,
#[serde(default)]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct KeylessCapabilities {
pub accepted_oidc_issuers: Vec<String>,
pub accepted_spiffe_trust_domains: Vec<String>,
pub max_attestation_ttl_seconds: u32,
}
impl crate::capabilities::CapabilitiesDocument {
pub fn keyless(&self) -> Result<Option<KeylessCapabilities>, AcdpError> {
let Some(raw) = self.extensions.get("keyless") else {
return Ok(None);
};
let caps = KeylessCapabilities::deserialize(raw)
.map_err(|e| AcdpError::SchemaViolation(format!("capabilities.keyless: {e}")))?;
if !(60..=3600).contains(&caps.max_attestation_ttl_seconds) {
return Err(AcdpError::SchemaViolation(
"capabilities.keyless.max_attestation_ttl_seconds must be 60 to 3600".into(),
));
}
Ok(Some(caps))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeylessSigner {
Controller,
Keyless {
identity: WorkloadIdentity,
org_wide: bool,
},
}
pub fn may_supersede(
predecessor: &KeylessSigner,
successor: &KeylessSigner,
) -> Result<(), KeylessReason> {
match (predecessor, successor) {
(_, KeylessSigner::Controller) => Ok(()),
(_, KeylessSigner::Keyless { org_wide: true, .. }) => Ok(()),
(
KeylessSigner::Keyless { identity: p, .. },
KeylessSigner::Keyless { identity: s, .. },
) if p == s => Ok(()),
_ => Err(KeylessReason::KeylessIdentityMismatch),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReceiptFacts {
pub verified: bool,
pub registry_is_attester: bool,
pub fingerprint_matches: bool,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeylessFacts {
pub attestation_valid: bool,
pub attester_accepted: bool,
pub bindings_ok: bool,
pub receipt: Option<ReceiptFacts>,
pub not_before: DateTime<Utc>,
pub not_after: DateTime<Utc>,
pub signature_valid: bool,
pub retracted: bool,
pub compromised_since: Vec<DateTime<Utc>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IdentityNow {
Listed,
NotListed,
Unreachable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeylessStatus {
CurrentlyAuthorized,
HistoricallyAuthorized,
HistoricallyAuthorizedPreCompromise,
}
pub fn decide_until_identity(f: &KeylessFacts) -> Result<Option<KeylessStatus>, KeylessReason> {
if !f.attestation_valid {
return Err(KeylessReason::AttestationInvalid);
}
if !f.attester_accepted {
return Err(KeylessReason::UntrustedAttester);
}
if !f.bindings_ok {
return Err(KeylessReason::AttestationMismatch);
}
let r = match &f.receipt {
Some(r) if r.verified && r.registry_is_attester && r.fingerprint_matches => r,
_ => return Err(KeylessReason::Unplaceable),
};
if !(f.not_before <= r.created_at && r.created_at < f.not_after) {
return Err(KeylessReason::OutsideAttestationWindow);
}
if !f.signature_valid {
return Err(KeylessReason::InvalidSignature);
}
if f.retracted {
return Err(KeylessReason::AttestationWithdrawn);
}
if let Some(t) = f.compromised_since.iter().min() {
if r.created_at >= *t {
return Err(KeylessReason::Revoked);
}
return Ok(Some(KeylessStatus::HistoricallyAuthorizedPreCompromise));
}
Ok(None)
}
pub fn classify(
f: &KeylessFacts,
identity_now: IdentityNow,
) -> Result<KeylessStatus, KeylessReason> {
if let Some(status) = decide_until_identity(f)? {
return Ok(status);
}
match identity_now {
IdentityNow::Listed => Ok(KeylessStatus::CurrentlyAuthorized),
IdentityNow::NotListed => Ok(KeylessStatus::HistoricallyAuthorized),
IdentityNow::Unreachable => Err(KeylessReason::ControllerUnreachable),
}
}
#[cfg(test)]
mod tests {
use super::*;
const EPH_PUB: &str = "34b4d9043156cb6dcf0beb0a2949b7559c940d2bcb6dbe8c53a9b30278e3a746";
fn eph() -> [u8; 32] {
hex::decode(EPH_PUB).unwrap().try_into().unwrap()
}
#[test]
fn jwk_fingerprint_thumbprint_and_key_id_match_kl_001() {
let jwk = Jwk::from_ed25519(&eph());
assert_eq!(jwk.x, "NLTZBDFWy23PC-sKKUm3VZyUDSvLbb6MU6mzAnjjp0Y");
assert_eq!(
jwk.fingerprint().unwrap(),
"sha256:f7b7676c94df7e8fd9998e38f9fff04d8588b00d1801764e3f1877a3feae4477"
);
assert_eq!(
jwk.thumbprint().unwrap(),
"Gec-ct57Gwltw072qu-aed20Ju93h-m8Qv8yjNgvMOM"
);
let id =
keyless_key_id("did:web:agents.example.com:ci", &jwk.fingerprint().unwrap()).unwrap();
assert!(is_keyless_key_id(&id));
assert!(!is_keyless_key_id("did:web:agents.example.com:ci#key-1"));
}
#[test]
fn identity_entries_and_services() {
let doc: DidDocument = serde_json::from_value(serde_json::json!({
"id": "did:web:a.example",
"service": [{
"id": "did:web:a.example#acdp-keyless",
"type": "AcdpKeylessSigning",
"serviceEndpoint": {"identities": [
{"type": "oidc", "issuer": "https://issuer.example", "subject": "repo:x"},
{"type": "spiffe", "subject": "spiffe://prod.example/sa/p", "org_wide": true},
{"type": "x509", "subject": "ignored"},
{"type": "oidc", "subject": "missing issuer"}
]}
}]
}))
.unwrap();
let listed = ListedIdentities::of(&doc);
assert_eq!(listed.entries().len(), 2);
let spiffe = WorkloadIdentity::Spiffe {
subject: "spiffe://prod.example/sa/p".into(),
};
assert!(listed.find(&spiffe).unwrap().org_wide);
let upper = WorkloadIdentity::Oidc {
issuer: "https://issuer.example".into(),
subject: "REPO:x".into(),
};
assert!(listed.find(&upper).is_none());
let mut two = doc.clone();
two.services.push(two.services[0].clone());
assert!(ListedIdentities::of(&two).entries().is_empty());
}
#[test]
fn descriptor_debug_redacts_the_token() {
let d = IdentityDescriptor::oidc("https://i.example", "s", "aaa.bbb.ccc");
assert!(!format!("{d:?}").contains("aaa.bbb.ccc"));
let x509 = IdentityDescriptor::Spiffe {
subject: "spiffe://prod.example/sa/p".into(),
svid_type: "x509-svid".into(),
proof: "aaa.bbb.ccc".into(),
};
assert!(x509.validate().is_err());
}
#[test]
fn presence_rule() {
assert!(check_key_attestation_presence("did:web:a#key-1", true).is_err());
assert!(check_key_attestation_presence("did:web:a#key-1", false).is_ok());
assert!(check_key_attestation_presence("did:web:a#acdp-kl-00", true).is_ok());
assert!(check_key_attestation_presence("did:web:a#acdp-kl-00", false).is_ok());
}
}