use chrono::{DateTime, Utc};
use vti_common::error::AppError;
use x509_parser::prelude::*;
const COSE_HEADER_X5CHAIN: i64 = 33;
#[derive(Debug)]
struct Anchor {
subject_der: Vec<u8>,
der: Vec<u8>,
}
#[derive(Debug)]
pub struct IacaTrustAnchors {
anchors: Vec<Anchor>,
}
impl IacaTrustAnchors {
pub fn from_pem(pems: &[String]) -> Result<Self, AppError> {
let mut anchors = Vec::new();
for (i, pem_text) in pems.iter().enumerate() {
let mut rest = pem_text.as_bytes();
let mut found = 0usize;
while let Ok((remaining, pem)) = x509_parser::pem::parse_x509_pem(rest) {
if pem.label != "CERTIFICATE" {
rest = remaining;
continue;
}
let cert = pem.parse_x509().map_err(|e| {
AppError::Validation(format!("IACA trust anchor {i} is not a valid X.509: {e}"))
})?;
let is_ca = cert
.basic_constraints()
.ok()
.flatten()
.map(|bc| bc.value.ca)
.unwrap_or(false);
if !is_ca {
return Err(AppError::Validation(format!(
"IACA trust anchor {i} ({}) is not a CA certificate — a Document \
Signer certificate cannot be used as a trust anchor",
cert.subject()
)));
}
anchors.push(Anchor {
subject_der: cert.subject().as_raw().to_vec(),
der: pem.contents.clone(),
});
found += 1;
rest = remaining;
}
if found == 0 {
return Err(AppError::Validation(format!(
"IACA trust anchor {i} contained no PEM CERTIFICATE block"
)));
}
}
Ok(Self { anchors })
}
pub fn is_empty(&self) -> bool {
self.anchors.is_empty()
}
pub fn resolve_issuer_key(
&self,
issuer_auth: &coset::CoseSign1,
now: DateTime<Utc>,
) -> Result<Vec<u8>, AppError> {
if self.anchors.is_empty() {
return Err(AppError::Validation(
"no IACA trust anchors are configured, so no mdoc issuer can be trusted"
.to_string(),
));
}
let leaf_der = extract_leaf_certificate(issuer_auth)?;
let (_, leaf) = X509Certificate::from_der(&leaf_der).map_err(|e| {
AppError::Validation(format!("mdoc x5chain leaf is not a valid X.509: {e}"))
})?;
let now_asn1 = ASN1Time::from_timestamp(now.timestamp())
.map_err(|e| AppError::Internal(format!("clock conversion: {e}")))?;
if !leaf.validity().is_valid_at(now_asn1) {
return Err(AppError::Validation(format!(
"mdoc Document Signer certificate is not valid at {} (validity {} .. {})",
now.to_rfc3339(),
leaf.validity().not_before,
leaf.validity().not_after
)));
}
if let Ok(Some(ku)) = leaf.key_usage()
&& !ku.value.digital_signature()
{
return Err(AppError::Validation(
"mdoc Document Signer certificate does not permit digitalSignature".to_string(),
));
}
let issuer_dn = leaf.issuer().as_raw();
let mut candidates = 0usize;
for anchor in &self.anchors {
if anchor.subject_der != issuer_dn {
continue;
}
candidates += 1;
let (_, root) = X509Certificate::from_der(&anchor.der).map_err(|e| {
AppError::Internal(format!("configured IACA anchor failed to re-parse: {e}"))
})?;
if leaf.verify_signature(Some(root.public_key())).is_ok() {
return Ok(leaf.public_key().subject_public_key.data.to_vec());
}
}
Err(AppError::Validation(if candidates == 0 {
format!(
"mdoc Document Signer was issued by `{}`, which is not a configured IACA \
trust anchor",
leaf.issuer()
)
} else {
format!(
"mdoc Document Signer claims issuer `{}` but its signature does not verify \
against the configured anchor for that name",
leaf.issuer()
)
}))
}
}
fn extract_leaf_certificate(issuer_auth: &coset::CoseSign1) -> Result<Vec<u8>, AppError> {
let entry = issuer_auth
.unprotected
.rest
.iter()
.find(|(label, _)| matches!(label, coset::Label::Int(COSE_HEADER_X5CHAIN)))
.map(|(_, value)| value)
.ok_or_else(|| {
AppError::Validation(
"mdoc issuerAuth carries no x5chain, so its issuer cannot be established"
.to_string(),
)
})?;
match entry {
coset::cbor::Value::Bytes(der) => Ok(der.clone()),
coset::cbor::Value::Array(certs) => match certs.first() {
Some(coset::cbor::Value::Bytes(der)) => Ok(der.clone()),
Some(other) => Err(AppError::Validation(format!(
"mdoc x5chain entries must be byte strings, got {other:?}"
))),
None => Err(AppError::Validation(
"mdoc x5chain is an empty array".to_string(),
)),
},
other => Err(AppError::Validation(format!(
"mdoc x5chain must be a byte string or an array of them, got {other:?}"
))),
}
}
pub fn mdoc_device_key_sec1(
mso: &affinidi_mdoc::MobileSecurityObject,
) -> Result<Vec<u8>, AppError> {
let cose =
affinidi_mdoc::CoseKey::from_cbor_value(&mso.device_key_info.device_key).map_err(|e| {
AppError::Validation(format!("mdoc deviceKey is not a valid COSE_Key: {e}"))
})?;
if !matches!(cose.crv, affinidi_mdoc::Curve::P256) {
return Err(AppError::Validation(format!(
"mdoc deviceKey must be P-256 (ISO 18013-5 / EUDI); got {:?}",
cose.crv
)));
}
let x = &cose.x;
let y = cose
.y
.as_ref()
.ok_or_else(|| AppError::Validation("mdoc deviceKey has no Y coordinate".to_string()))?;
if x.len() != 32 || y.len() != 32 {
return Err(AppError::Validation(format!(
"mdoc deviceKey coordinates must be 32 bytes each; got x={} y={}",
x.len(),
y.len()
)));
}
let prefix = if y[31] & 1 == 0 { 0x02u8 } else { 0x03u8 };
let mut point = Vec::with_capacity(33);
point.push(prefix);
point.extend_from_slice(x);
Ok(point)
}
#[cfg(test)]
mod tests {
use super::*;
use affinidi_mdoc::es256_cose::Es256CoseSigner;
use affinidi_mdoc::mso::ValidityInfo;
use coset::CoseSign1;
use rcgen::{
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose,
PKCS_ECDSA_P256_SHA256,
};
struct Iaca {
pem: String,
params: CertificateParams,
key: KeyPair,
}
fn iaca(common_name: &str) -> Iaca {
let mut params = CertificateParams::new(vec![]).unwrap();
params
.distinguished_name
.push(DnType::CommonName, common_name);
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
let key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
let cert = params.self_signed(&key).unwrap();
Iaca {
pem: cert.pem(),
params,
key,
}
}
fn document_signer(root: &Iaca, common_name: &str) -> (Vec<u8>, KeyPair) {
let mut params = CertificateParams::new(vec![]).unwrap();
params
.distinguished_name
.push(DnType::CommonName, common_name);
params.is_ca = IsCa::NoCa;
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
let ds_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap();
let issuer = Issuer::from_params(&root.params, &root.key);
let cert = params.signed_by(&ds_key, &issuer).unwrap();
(cert.der().to_vec(), ds_key)
}
fn issuer_auth_with_chain(chain: Vec<Vec<u8>>) -> CoseSign1 {
let signer = Es256CoseSigner::generate();
let mso = affinidi_mdoc::MdocBuilder::new("eu.europa.ec.eudi.pid.1")
.validity(ValidityInfo {
signed: "2026-01-01T00:00:00Z".into(),
valid_from: "2026-01-01T00:00:00Z".into(),
valid_until: "2036-01-01T00:00:00Z".into(),
})
.build(&signer)
.unwrap();
let mut sign1 = mso.issuer_auth;
sign1
.unprotected
.rest
.retain(|(l, _)| !matches!(l, coset::Label::Int(x) if *x == COSE_HEADER_X5CHAIN));
let value = if chain.len() == 1 {
coset::cbor::Value::Bytes(chain[0].clone())
} else {
coset::cbor::Value::Array(chain.into_iter().map(coset::cbor::Value::Bytes).collect())
};
sign1
.unprotected
.rest
.push((coset::Label::Int(COSE_HEADER_X5CHAIN), value));
sign1
}
fn now() -> DateTime<Utc> {
Utc::now()
}
#[test]
fn a_document_signer_under_a_configured_root_resolves() {
let root = iaca("Test IACA");
let (ds_der, _) = document_signer(&root, "Test Document Signer");
let anchors = IacaTrustAnchors::from_pem(std::slice::from_ref(&root.pem)).unwrap();
let key = anchors
.resolve_issuer_key(&issuer_auth_with_chain(vec![ds_der]), now())
.expect("a DS issued by the configured root must resolve");
assert!(!key.is_empty(), "an EC point should come back");
}
#[test]
fn a_document_signer_under_an_unconfigured_root_is_refused() {
let trusted = iaca("Trusted IACA");
let rogue = iaca("Rogue IACA");
let (rogue_ds, _) = document_signer(&rogue, "Rogue Document Signer");
let anchors = IacaTrustAnchors::from_pem(std::slice::from_ref(&trusted.pem)).unwrap();
let err = anchors
.resolve_issuer_key(&issuer_auth_with_chain(vec![rogue_ds]), now())
.unwrap_err();
assert!(
matches!(&err, AppError::Validation(m) if m.contains("not a configured IACA")),
"{err:?}"
);
}
#[test]
fn a_matching_issuer_name_without_a_matching_signature_is_refused() {
let trusted = iaca("Shared Name IACA");
let impostor = iaca("Shared Name IACA");
let (impostor_ds, _) = document_signer(&impostor, "Impostor DS");
let anchors = IacaTrustAnchors::from_pem(std::slice::from_ref(&trusted.pem)).unwrap();
let err = anchors
.resolve_issuer_key(&issuer_auth_with_chain(vec![impostor_ds]), now())
.unwrap_err();
assert!(
matches!(&err, AppError::Validation(m) if m.contains("does not verify")),
"a DN match must not be sufficient; got {err:?}"
);
}
#[test]
fn an_empty_anchor_set_fails_closed() {
let root = iaca("Test IACA");
let (ds_der, _) = document_signer(&root, "DS");
let anchors = IacaTrustAnchors::from_pem(&[]).unwrap();
assert!(anchors.is_empty());
let err = anchors
.resolve_issuer_key(&issuer_auth_with_chain(vec![ds_der]), now())
.unwrap_err();
assert!(
matches!(&err, AppError::Validation(m) if m.contains("no IACA trust anchors")),
"an unconfigured deployment must refuse, not accept everything; got {err:?}"
);
}
#[test]
fn a_non_ca_certificate_is_refused_as_an_anchor() {
let root = iaca("Test IACA");
let (ds_der, _) = document_signer(&root, "Not A CA");
let ds_pem = pem_wrap(&ds_der);
let err = IacaTrustAnchors::from_pem(&[ds_pem]).unwrap_err();
assert!(
matches!(&err, AppError::Validation(m) if m.contains("not a CA certificate")),
"{err:?}"
);
}
#[test]
fn an_issuer_auth_without_an_x5chain_is_refused() {
let root = iaca("Test IACA");
let anchors = IacaTrustAnchors::from_pem(std::slice::from_ref(&root.pem)).unwrap();
let signer = Es256CoseSigner::generate();
let mso = affinidi_mdoc::MdocBuilder::new("eu.europa.ec.eudi.pid.1")
.validity(ValidityInfo {
signed: "2026-01-01T00:00:00Z".into(),
valid_from: "2026-01-01T00:00:00Z".into(),
valid_until: "2036-01-01T00:00:00Z".into(),
})
.build(&signer)
.unwrap();
let err = anchors
.resolve_issuer_key(&mso.issuer_auth, now())
.unwrap_err();
assert!(
matches!(&err, AppError::Validation(m) if m.contains("no x5chain")),
"{err:?}"
);
}
#[test]
fn both_x5chain_encodings_are_accepted() {
let root = iaca("Test IACA");
let (ds_der, _) = document_signer(&root, "DS");
let anchors = IacaTrustAnchors::from_pem(std::slice::from_ref(&root.pem)).unwrap();
let single = anchors
.resolve_issuer_key(&issuer_auth_with_chain(vec![ds_der.clone()]), now())
.expect("bare bstr");
let root_der = pem_to_der(&root.pem);
let array = anchors
.resolve_issuer_key(&issuer_auth_with_chain(vec![ds_der, root_der]), now())
.expect("array form");
assert_eq!(single, array, "both encodings must yield the same DS key");
}
#[test]
fn a_bundle_of_several_roots_in_one_value_parses() {
let a = iaca("IACA A");
let b = iaca("IACA B");
let bundle = format!("{}{}", a.pem, b.pem);
let anchors = IacaTrustAnchors::from_pem(&[bundle]).unwrap();
for root in [&a, &b] {
let (ds, _) = document_signer(root, "DS");
anchors
.resolve_issuer_key(&issuer_auth_with_chain(vec![ds]), now())
.expect("each root in the bundle must be usable");
}
}
#[test]
fn a_value_with_no_certificate_block_is_refused() {
let err = IacaTrustAnchors::from_pem(&["not a pem".to_string()]).unwrap_err();
assert!(
matches!(&err, AppError::Validation(m) if m.contains("no PEM CERTIFICATE block")),
"{err:?}"
);
}
fn pem_wrap(der: &[u8]) -> String {
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(der);
let body = b64
.as_bytes()
.chunks(64)
.map(|c| String::from_utf8_lossy(c).to_string())
.collect::<Vec<_>>()
.join("\n");
format!("-----BEGIN CERTIFICATE-----\n{body}\n-----END CERTIFICATE-----\n")
}
fn pem_to_der(pem: &str) -> Vec<u8> {
let (_, parsed) = x509_parser::pem::parse_x509_pem(pem.as_bytes()).unwrap();
parsed.contents
}
}