diff --git a/Cargo.toml b/Cargo.toml index 0ffaa348..0d3587d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,16 +10,20 @@ documentation = "https://docs.rs/isomdl" license = "Apache-2.0 OR MIT" exclude = ["test/"] +[[bin]] +name = "isomdl-utils" +path = "src/bin/utils.rs" + [dependencies] anyhow = "1.0" -ecdsa = { version = "0.16.0", features = ["serde"] } +ecdsa = { version = "0.16.9", features = ["serde", "verifying"] } p256 = { version = "0.13.0", features = ["serde", "ecdh"] } p384 = { version = "0.13.0", features = ["serde", "ecdh"] } rand = { version = "0.8.5", features = ["getrandom"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_bytes = "0.11.0" -sha2 = "0.10.6" +sha2 = { version = "0.10.8", features = ["oid"] } thiserror = "1.0" elliptic-curve = "0.13.1" hkdf = "0.12.3" @@ -36,12 +40,15 @@ async-signature = "0.3.0" #tracing = "0.1" base64 = "0.13" pem-rfc7468 = "0.7.0" -x509-cert = { version = "0.1.1", features = ["pem"] } - +x509-cert = { version = "0.2.4", features = ["pem", "builder"] } ssi-jwk = "0.2.1" isomdl-macros = { version = "0.1.0", path = "macros" } clap = { version = "4", features = ["derive"] } clap-stdin = "0.2.1" +const-oid = "0.9.2" +der = { version = "0.7", features = ["std", "derive", "alloc"] } +hex = "0.4.3" +asn1-rs = { version = "0.5.2", features = ["bits"] } strum = "0.24" strum_macros = "0.24" @@ -49,8 +56,13 @@ strum_macros = "0.24" coset = "0.3.8" ciborium = "0.2.2" digest = "0.10.7" +tracing = "0.1.41" +sha1 = "0.10.6" [dev-dependencies] hex = "0.4.3" p256 = "0.13.0" +rstest = "0.23.0" serde_json = "*" +test-log = { version = "0.2.16", features = ["trace"] } +x509-cert = { version = "0.2.4", features = ["pem", "builder", "hazmat"] } diff --git a/src/bin/utils.rs b/src/bin/utils.rs new file mode 100644 index 00000000..de900335 --- /dev/null +++ b/src/bin/utils.rs @@ -0,0 +1,83 @@ +use std::{collections::BTreeMap, fs::File, io::Read, path::PathBuf}; + +use anyhow::{Context, Error, Ok}; +use clap::Parser; +use clap_stdin::MaybeStdin; +use isomdl::presentation::{device::Document, Stringify}; + +mod x509; + +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Args { + #[command(subcommand)] + action: Action, +} + +#[derive(Debug, clap::Subcommand)] +enum Action { + /// Print the namespaces and element identifiers used in an mDL. + GetNamespaces { + /// Base64 encoded mDL in the format used in the issuance module of this crate. + mdl: MaybeStdin, + }, + /// Validate a document signer cert against a possible root certificate. + ValidateCerts { + /// Validation rule set. + rules: RuleSet, + /// Path to PEM-encoded document signer cert. + ds: PathBuf, + /// Path to PEM-encoded IACA root cert. + root: PathBuf, + }, +} + +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +enum RuleSet { + Iaca, + Aamva, +} + +fn main() -> Result<(), Error> { + match Args::parse().action { + Action::GetNamespaces { mdl } => print_namespaces(mdl.to_string()), + Action::ValidateCerts { rules, ds, root } => validate_certs(rules, ds, root), + } +} + +fn print_namespaces(mdl: String) -> Result<(), Error> { + let claims = Document::parse(mdl) + .context("could not parse mdl")? + .namespaces + .into_inner() + .into_iter() + .map(|(ns, inner)| (ns, inner.into_inner().into_keys().collect())) + .collect::>>(); + println!("{}", serde_json::to_string_pretty(&claims)?); + Ok(()) +} + +fn validate_certs(rules: RuleSet, ds: PathBuf, root: PathBuf) -> Result<(), Error> { + let mut ds_bytes = vec![]; + File::open(ds)?.read_to_end(&mut ds_bytes)?; + let mut root_bytes = vec![]; + File::open(root)?.read_to_end(&mut root_bytes)?; + let validation_errors = x509::validate(rules, &ds_bytes, &root_bytes)?; + if validation_errors.is_empty() { + println!("Validated!"); + } else { + println!( + "Validation errors:\n{}", + serde_json::to_string_pretty(&validation_errors)? + ) + } + Ok(()) +} + +#[cfg(test)] +mod test { + #[test] + fn print_namespaces() { + super::print_namespaces(include_str!("../../test/stringified-mdl.txt").to_string()).unwrap() + } +} diff --git a/src/bin/x509/mod.rs b/src/bin/x509/mod.rs new file mode 100644 index 00000000..4ed285fc --- /dev/null +++ b/src/bin/x509/mod.rs @@ -0,0 +1,32 @@ +use der::DecodePem; +use isomdl::definitions::x509::{ + trust_anchor::{TrustAnchor, TrustAnchorRegistry, TrustPurpose}, + validation::ValidationRuleset, + X5Chain, +}; +use x509_cert::Certificate; + +use crate::RuleSet; + +pub fn validate(rules: RuleSet, signer: &[u8], root: &[u8]) -> Result, anyhow::Error> { + let root = Certificate::from_pem(root)?; + + let trust_anchor = TrustAnchor { + certificate: root, + purpose: TrustPurpose::Iaca, + }; + + let trust_anchor_registry = TrustAnchorRegistry { + anchors: vec![trust_anchor], + }; + + let x5chain = X5Chain::builder().with_pem_certificate(signer)?.build()?; + + let outcome = match rules { + RuleSet::Iaca => ValidationRuleset::Mdl, + RuleSet::Aamva => ValidationRuleset::AamvaMdl, + } + .validate(&x5chain, &trust_anchor_registry); + + Ok(outcome.errors) +} diff --git a/src/cose/mac0.rs b/src/cose/mac0.rs index 0d99eed6..b00f4ddb 100644 --- a/src/cose/mac0.rs +++ b/src/cose/mac0.rs @@ -218,8 +218,6 @@ mod hmac { use super::super::SignatureAlgorithm; - /// Implement [`SignatureAlgorithm`]. - impl SignatureAlgorithm for Hmac { fn algorithm(&self) -> iana::Algorithm { iana::Algorithm::HMAC_256_256 diff --git a/src/cose/serialized_as_cbor_value.rs b/src/cose/serialized_as_cbor_value.rs index 04dc815b..ee07ef21 100644 --- a/src/cose/serialized_as_cbor_value.rs +++ b/src/cose/serialized_as_cbor_value.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; /// implement `Serialize`/`Deserialize` but only `AsCborValue`. pub struct SerializedAsCborValue(pub T); -impl<'a, T: Clone + AsCborValue> Serialize for SerializedAsCborValue<&'a T> { +impl Serialize for SerializedAsCborValue<&T> { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, diff --git a/src/cose/sign1.rs b/src/cose/sign1.rs index e3e8e51d..b47483b0 100644 --- a/src/cose/sign1.rs +++ b/src/cose/sign1.rs @@ -231,8 +231,6 @@ mod p256 { use crate::cose::SignatureAlgorithm; - /// Implement [`SignatureAlgorithm`]. - impl SignatureAlgorithm for SigningKey { fn algorithm(&self) -> iana::Algorithm { iana::Algorithm::ES256 @@ -252,8 +250,6 @@ mod p384 { use crate::cose::SignatureAlgorithm; - /// Implement [`SignatureAlgorithm`]. - impl SignatureAlgorithm for SigningKey { fn algorithm(&self) -> iana::Algorithm { iana::Algorithm::ES384 diff --git a/src/definitions/device_engagement.rs b/src/definitions/device_engagement.rs index 9edf7e0c..cdf0c24e 100644 --- a/src/definitions/device_engagement.rs +++ b/src/definitions/device_engagement.rs @@ -1,6 +1,6 @@ //! This module contains the definitions for the [DeviceEngagement] struct and related types. //! -//! The [DeviceEngagement] struct represents a device engagement object, which contains information about a device's engagement with a server. +//! The [DeviceEngagement] struct represents a device engagement object, which contains information about a device's engagement with a server. //! It includes fields such as the `version`, `security details, `device retrieval methods, `server retrieval methods, and `protocol information. //! //! The module also provides implementations for conversions between [DeviceEngagement] and [ciborium::Value], as well as other utility functions. @@ -76,7 +76,7 @@ pub enum DeviceRetrievalMethod { /// Represents the BLE options for device engagement. /// - /// This struct is used to configure the BLE options for device engagement. + /// This struct is used to configure the BLE options for device engagement. /// It contains the necessary parameters and settings for BLE communication. BLE(BleOptions), diff --git a/src/definitions/device_engagement/error.rs b/src/definitions/device_engagement/error.rs index 365cc731..65be542c 100644 --- a/src/definitions/device_engagement/error.rs +++ b/src/definitions/device_engagement/error.rs @@ -5,7 +5,7 @@ use crate::definitions::helpers::tag24::Error as Tag24Error; /// Errors that can occur when deserialising a DeviceEngagement. #[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)] pub enum Error { - #[error("Expected isomdl version 1.0")] + #[error("Expected isomdl major version 1")] UnsupportedVersion, #[error("Unsupported device retrieval method")] UnsupportedDRM, diff --git a/src/definitions/device_request.rs b/src/definitions/device_request.rs index 03470aaf..3935f5ef 100644 --- a/src/definitions/device_request.rs +++ b/src/definitions/device_request.rs @@ -17,7 +17,6 @@ pub type ReaderAuth = MaybeTagged; /// Represents a device request. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] - pub struct DeviceRequest { /// The version of the device request. pub version: String, diff --git a/src/definitions/helpers/non_empty_vec.rs b/src/definitions/helpers/non_empty_vec.rs index 154f7d40..21123f50 100644 --- a/src/definitions/helpers/non_empty_vec.rs +++ b/src/definitions/helpers/non_empty_vec.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use std::ops::Deref; -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)] #[serde(try_from = "Vec", into = "Vec")] pub struct NonEmptyVec(Vec); diff --git a/src/definitions/mod.rs b/src/definitions/mod.rs index cf8954c2..e5b3a185 100644 --- a/src/definitions/mod.rs +++ b/src/definitions/mod.rs @@ -11,6 +11,7 @@ pub mod namespaces; pub mod session; pub mod traits; pub mod validity_info; +pub mod x509; pub use device_engagement::{ BleOptions, DeviceEngagement, DeviceRetrievalMethod, NfcOptions, Security, WifiOptions, diff --git a/src/definitions/namespaces/latin1.rs b/src/definitions/namespaces/latin1.rs index d7fb62fa..c94b8d21 100644 --- a/src/definitions/namespaces/latin1.rs +++ b/src/definitions/namespaces/latin1.rs @@ -95,12 +95,12 @@ mod test { fn upper_latin() { #[allow(clippy::invisible_characters)] let upper_latin_chars = vec![ - ' ', '¡', '¢', '£', '¤', '¥', '¦', '§', '¨', '©', 'ª', '«', '¬', '­', '®', '¯', '°', - '±', '²', '³', '´', 'µ', '¶', '·', '¸', '¹', 'º', '»', '¼', '½', '¾', '¿', 'À', 'Á', - 'Â', 'Ã', 'Ä', 'Å', 'Æ', 'Ç', 'È', 'É', 'Ê', 'Ë', 'Ì', 'Í', 'Î', 'Ï', 'Ð', 'Ñ', 'Ò', - 'Ó', 'Ô', 'Õ', 'Ö', '×', 'Ø', 'Ù', 'Ú', 'Û', 'Ü', 'Ý', 'Þ', 'ß', 'à', 'á', 'â', 'ã', - 'ä', 'å', 'æ', 'ç', 'è', 'é', 'ê', 'ë', 'ì', 'í', 'î', 'ï', 'ð', 'ñ', 'ò', 'ó', 'ô', - 'õ', 'ö', '÷', 'ø', 'ù', 'ú', 'û', 'ü', 'ý', 'þ', 'ÿ', + ' ', '¡', '¢', '£', '¤', '¥', '¦', '§', '¨', '©', 'ª', '«', '¬', '\u{AD}', '®', '¯', + '°', '±', '²', '³', '´', 'µ', '¶', '·', '¸', '¹', 'º', '»', '¼', '½', '¾', '¿', 'À', + 'Á', 'Â', 'Ã', 'Ä', 'Å', 'Æ', 'Ç', 'È', 'É', 'Ê', 'Ë', 'Ì', 'Í', 'Î', 'Ï', 'Ð', 'Ñ', + 'Ò', 'Ó', 'Ô', 'Õ', 'Ö', '×', 'Ø', 'Ù', 'Ú', 'Û', 'Ü', 'Ý', 'Þ', 'ß', 'à', 'á', 'â', + 'ã', 'ä', 'å', 'æ', 'ç', 'è', 'é', 'ê', 'ë', 'ì', 'í', 'î', 'ï', 'ð', 'ñ', 'ò', 'ó', + 'ô', 'õ', 'ö', '÷', 'ø', 'ù', 'ú', 'û', 'ü', 'ý', 'þ', 'ÿ', ]; assert!(upper_latin_chars.iter().all(is_upper_latin)); } diff --git a/src/definitions/x509/mod.rs b/src/definitions/x509/mod.rs new file mode 100644 index 00000000..c3918282 --- /dev/null +++ b/src/definitions/x509/mod.rs @@ -0,0 +1,229 @@ +pub mod trust_anchor; +mod util; +pub mod validation; +pub mod x5chain; + +pub use x5chain::{Builder, X5Chain}; + +#[cfg(test)] +mod test { + use std::time::Duration; + + use const_oid::ObjectIdentifier; + use der::asn1::OctetString; + use p256::NistP256; + use rand::random; + use sec1::pkcs8::EncodePublicKey; + use sha1::{Digest, Sha1}; + use signature::{Keypair, KeypairRef, Signer}; + use x509_cert::{ + builder::{Builder, CertificateBuilder}, + ext::pkix::{ + crl::dp::DistributionPoint, + name::{DistributionPointName, GeneralName}, + AuthorityKeyIdentifier, BasicConstraints, CrlDistributionPoints, ExtendedKeyUsage, + IssuerAltName, KeyUsage, KeyUsages, SubjectKeyIdentifier, + }, + name::Name, + spki::{ + DynSignatureAlgorithmIdentifier, SignatureBitStringEncoding, SubjectPublicKeyInfoOwned, + }, + time::Validity, + Certificate, + }; + + use super::validation; + + fn prepare_root_certificate(root_key: &S, issuer: Name) -> CertificateBuilder<'_, S> + where + S: KeypairRef + DynSignatureAlgorithmIdentifier, + S::VerifyingKey: EncodePublicKey, + { + let spki = SubjectPublicKeyInfoOwned::from_key(root_key.verifying_key()).unwrap(); + let ski_digest = Sha1::digest(spki.subject_public_key.raw_bytes()); + let ski_digest_octet = OctetString::new(ski_digest.to_vec()).unwrap(); + + let mut builder = CertificateBuilder::new( + x509_cert::builder::Profile::Manual { issuer: None }, + random::().into(), + Validity::from_now(Duration::from_secs(600)).unwrap(), + issuer, + spki, + root_key, + ) + .unwrap(); + + builder + .add_extension(&SubjectKeyIdentifier(ski_digest_octet)) + .unwrap(); + + builder + .add_extension(&KeyUsage(KeyUsages::KeyCertSign | KeyUsages::CRLSign)) + .unwrap(); + + builder + .add_extension(&BasicConstraints { + ca: true, + path_len_constraint: Some(0), + }) + .unwrap(); + + builder + .add_extension(&IssuerAltName(vec![GeneralName::Rfc822Name( + "test@example.com".to_string().try_into().unwrap(), + )])) + .unwrap(); + + builder + .add_extension(&CrlDistributionPoints(vec![DistributionPoint { + distribution_point: Some(DistributionPointName::FullName(vec![ + GeneralName::UniformResourceIdentifier( + "http://example.com".to_string().try_into().unwrap(), + ), + ])), + reasons: None, + crl_issuer: None, + }])) + .unwrap(); + + builder + } + + fn prepare_signer_certificate<'s, S>( + signer_key: &'s S, + root_key: &'s S, + issuer: Name, + ) -> CertificateBuilder<'s, S> + where + S: KeypairRef + DynSignatureAlgorithmIdentifier, + S::VerifyingKey: EncodePublicKey, + { + let spki = SubjectPublicKeyInfoOwned::from_key(signer_key.verifying_key()).unwrap(); + let ski_digest = Sha1::digest(spki.subject_public_key.raw_bytes()); + let ski_digest_octet = OctetString::new(ski_digest.to_vec()).unwrap(); + + let apki = SubjectPublicKeyInfoOwned::from_key(root_key.verifying_key()).unwrap(); + let aki_digest = Sha1::digest(apki.subject_public_key.raw_bytes()); + let aki_digest_octet = OctetString::new(aki_digest.to_vec()).unwrap(); + + let mut builder = CertificateBuilder::new( + x509_cert::builder::Profile::Manual { + issuer: Some(issuer), + }, + random::().into(), + Validity::from_now(Duration::from_secs(600)).unwrap(), + "CN=subject,C=US".parse().unwrap(), + spki, + root_key, + ) + .unwrap(); + + builder + .add_extension(&SubjectKeyIdentifier(ski_digest_octet)) + .unwrap(); + + builder + .add_extension(&AuthorityKeyIdentifier { + key_identifier: Some(aki_digest_octet), + ..Default::default() + }) + .unwrap(); + + builder + .add_extension(&KeyUsage(KeyUsages::DigitalSignature.into())) + .unwrap(); + + builder + .add_extension(&IssuerAltName(vec![GeneralName::Rfc822Name( + "test@example.com".to_string().try_into().unwrap(), + )])) + .unwrap(); + + builder + .add_extension(&CrlDistributionPoints(vec![DistributionPoint { + distribution_point: Some(DistributionPointName::FullName(vec![ + GeneralName::UniformResourceIdentifier( + "http://example.com".to_string().try_into().unwrap(), + ), + ])), + reasons: None, + crl_issuer: None, + }])) + .unwrap(); + + builder + .add_extension(&ExtendedKeyUsage(vec![ObjectIdentifier::new_unwrap( + "1.0.18013.5.1.2", + )])) + .unwrap(); + + builder + } + + fn setup() -> (Certificate, Certificate) { + let root_key = p256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + let signer_key = p256::ecdsa::SigningKey::random(&mut rand::thread_rng()); + + let issuer: Name = "CN=issuer,C=US".parse().unwrap(); + + let mut prepared_root_certificate = prepare_root_certificate(&root_key, issuer.clone()); + let signature: ecdsa::Signature = + root_key.sign(&prepared_root_certificate.finalize().unwrap()); + let root_certificate: Certificate = prepared_root_certificate + .assemble(signature.to_der().to_bitstring().unwrap()) + .unwrap(); + + let mut prepared_signer_certificate = + prepare_signer_certificate(&signer_key, &root_key, issuer.clone()); + let signature: ecdsa::Signature = + root_key.sign(&prepared_signer_certificate.finalize().unwrap()); + let signer_certificate: Certificate = prepared_signer_certificate + .assemble(signature.to_der().to_bitstring().unwrap()) + .unwrap(); + + assert!(validation::signature::issuer_signed_subject( + &signer_certificate, + &root_certificate + )); + + (root_certificate, signer_certificate) + } + + mod iaca { + use der::EncodePem; + + use crate::definitions::x509::{ + trust_anchor::{TrustAnchor, TrustAnchorRegistry, TrustPurpose}, + validation::ValidationRuleset, + X5Chain, + }; + + #[test_log::test] + fn valid_mdoc_issuer_certificate_chain_is_validated() { + let (root, signer) = super::setup(); + + tracing::debug!( + "issuer certificate:\n{}", + root.to_pem(Default::default()).unwrap() + ); + tracing::debug!( + "signer certificate:\n{}", + signer.to_pem(Default::default()).unwrap() + ); + + let trust_anchor_registry = TrustAnchorRegistry { + anchors: vec![TrustAnchor { + certificate: root, + purpose: TrustPurpose::Iaca, + }], + }; + let x5chain = X5Chain::builder() + .with_certificate(signer) + .unwrap() + .build() + .unwrap(); + let outcome = ValidationRuleset::Mdl.validate(&x5chain, &trust_anchor_registry); + assert!(outcome.success(), "{outcome:?}"); + } + } +} diff --git a/src/definitions/x509/trust_anchor.rs b/src/definitions/x509/trust_anchor.rs new file mode 100644 index 00000000..e31ebf8e --- /dev/null +++ b/src/definitions/x509/trust_anchor.rs @@ -0,0 +1,94 @@ +use anyhow::Error; +use der::{DecodePem, EncodePem}; +use serde::{Deserialize, Serialize}; +use x509_cert::Certificate; + +/// A collection of roots of trust, each with a specific purpose. +#[derive(Clone, Serialize, Deserialize, Default)] +pub struct TrustAnchorRegistry { + /// The roots of trust in this registry. + pub anchors: Vec, +} + +/// A root of trust for a specific purpose. +#[derive(Debug, Clone)] +pub struct TrustAnchor { + pub certificate: Certificate, + pub purpose: TrustPurpose, +} + +/// Identifies what purpose the certificate is trusted for. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum TrustPurpose { + /// Issuer Authority Certificate Authority as defined in 18013-5. + Iaca, + /// Reader Certificate Authority as defined in 18013-5. + ReaderCa, +} + +/// PEM representation of a TrustAnchor, used for serialization and deserialization only. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PemTrustAnchor { + pub certificate_pem: String, + pub purpose: TrustPurpose, +} + +impl TrustAnchorRegistry { + /// Build a trust anchor registry from PEM certificates. + pub fn from_pem_certificates(certs: Vec) -> Result { + Ok(Self { + anchors: certs + .into_iter() + .map(TrustAnchor::try_from) + .collect::>()?, + }) + } +} + +impl<'l> TryFrom<&'l TrustAnchor> for PemTrustAnchor { + type Error = Error; + + fn try_from(value: &'l TrustAnchor) -> Result { + Ok(Self { + certificate_pem: value.certificate.to_pem(Default::default())?, + purpose: value.purpose, + }) + } +} + +impl TryFrom for TrustAnchor { + type Error = Error; + + fn try_from(value: PemTrustAnchor) -> Result { + Ok(Self { + certificate: Certificate::from_pem(&value.certificate_pem)?, + purpose: value.purpose, + }) + } +} + +impl Serialize for TrustAnchor { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::Error; + + PemTrustAnchor::try_from(self) + .map_err(S::Error::custom)? + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for TrustAnchor { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + + PemTrustAnchor::deserialize(deserializer)? + .try_into() + .map_err(D::Error::custom) + } +} diff --git a/src/definitions/x509/util.rs b/src/definitions/x509/util.rs new file mode 100644 index 00000000..1a02fe00 --- /dev/null +++ b/src/definitions/x509/util.rs @@ -0,0 +1,62 @@ +use anyhow::{Context, Error}; +use const_oid::{db::rfc4519::COMMON_NAME, AssociatedOid}; +use der::{ + asn1::{Ia5StringRef, PrintableStringRef, TeletexStringRef, Utf8StringRef}, + referenced::OwnedToRef, + Tag, Tagged, +}; +use ecdsa::{PrimeCurve, VerifyingKey}; +use elliptic_curve::{ + sec1::{FromEncodedPoint, ToEncodedPoint}, + AffinePoint, CurveArithmetic, FieldBytesSize, PublicKey, +}; +use sec1::point::ModulusSize; +use x509_cert::{attr::AttributeValue, Certificate}; + +/// Get the public key from a certificate for verification. +pub fn public_key(certificate: &Certificate) -> Result, Error> +where + C: AssociatedOid + CurveArithmetic + PrimeCurve, + AffinePoint: FromEncodedPoint + ToEncodedPoint, + FieldBytesSize: ModulusSize, +{ + certificate + .tbs_certificate + .subject_public_key_info + .owned_to_ref() + .try_into() + .map(|key: PublicKey| key.into()) + .context("could not parse public key from PKCS8 SPKI") +} + +/// Get the first CommonName of the X.509 certificate, or return "Unknown". +pub fn common_name_or_unknown(certificate: &Certificate) -> &str { + common_name(certificate).unwrap_or("Unknown") +} + +fn common_name(certificate: &Certificate) -> Option<&str> { + certificate + .tbs_certificate + .subject + .0 + .iter() + .flat_map(|rdn| rdn.0.iter()) + .filter_map(|attribute| { + if attribute.oid == COMMON_NAME { + attribute_value_to_str(&attribute.value) + } else { + None + } + }) + .next() +} + +pub fn attribute_value_to_str(av: &AttributeValue) -> Option<&str> { + match av.tag() { + Tag::PrintableString => PrintableStringRef::try_from(av).ok().map(|s| s.as_str()), + Tag::Utf8String => Utf8StringRef::try_from(av).ok().map(|s| s.as_str()), + Tag::Ia5String => Ia5StringRef::try_from(av).ok().map(|s| s.as_str()), + Tag::TeletexString => TeletexStringRef::try_from(av).ok().map(|s| s.as_str()), + _ => None, + } +} diff --git a/src/definitions/x509/validation/error.rs b/src/definitions/x509/validation/error.rs new file mode 100644 index 00000000..16c53334 --- /dev/null +++ b/src/definitions/x509/validation/error.rs @@ -0,0 +1,75 @@ +use std::fmt; + +#[derive(Debug, Clone, Copy)] +pub struct ErrorWithContext { + context: ErrorContext, + error: E, +} + +impl ErrorWithContext { + pub fn comparison(error: E) -> String { + Self { + context: ErrorContext::Comparison, + error, + } + .to_string() + } + + pub fn ds(error: E) -> String { + Self { + context: ErrorContext::DocumentSigner, + error, + } + .to_string() + } + + pub fn iaca(error: E) -> String { + Self { + context: ErrorContext::Iaca, + error, + } + .to_string() + } + + pub fn reader(error: E) -> String { + Self { + context: ErrorContext::Reader, + error, + } + .to_string() + } + + pub fn reader_ca(error: E) -> String { + Self { + context: ErrorContext::ReaderCa, + error, + } + .to_string() + } +} + +impl fmt::Display for ErrorWithContext { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} error: {}", + match self.context { + ErrorContext::Comparison => "Comparison", + ErrorContext::DocumentSigner => "DS certificate", + ErrorContext::Iaca => "IACA certificate", + ErrorContext::Reader => "Reader certificate", + ErrorContext::ReaderCa => "Reader CA certificate", + }, + self.error, + ) + } +} + +#[derive(Debug, Clone, Copy)] +enum ErrorContext { + Comparison, + DocumentSigner, + Iaca, + Reader, + ReaderCa, +} diff --git a/src/definitions/x509/validation/extensions/basic_constraints.rs b/src/definitions/x509/validation/extensions/basic_constraints.rs new file mode 100644 index 00000000..7fd67b80 --- /dev/null +++ b/src/definitions/x509/validation/extensions/basic_constraints.rs @@ -0,0 +1,68 @@ +use const_oid::AssociatedOid; +use der::Decode; +use x509_cert::ext::{pkix::BasicConstraints, Extension}; + +use super::{Error, ExtensionValidator}; + +/// BasicConstraints validation for IACA certificate. +pub struct BasicConstraintsValidator; + +impl BasicConstraintsValidator { + fn check(constraints: BasicConstraints) -> Option { + if constraints + .path_len_constraint + .is_none_or(|path_len| path_len != 0) + || !constraints.ca + { + Some(format!( + "expected to be CA:true, path_len:0, but found: {:?}", + constraints + )) + } else { + None + } + } +} + +impl ExtensionValidator for BasicConstraintsValidator { + fn oid(&self) -> const_oid::ObjectIdentifier { + BasicConstraints::OID + } + + fn ext_name(&self) -> &'static str { + "BasicConstraints" + } + + fn validate(&self, extension: &Extension) -> Vec { + let mut errors = vec![]; + + if !extension.critical { + tracing::warn!("expected BasicConstraints extension to be critical",) + } + + let bytes = extension.extn_value.as_bytes(); + let basic_constraints = BasicConstraints::from_der(bytes); + match basic_constraints { + Ok(bc) => { + if let Some(e) = Self::check(bc) { + errors.push(e); + } + } + Err(e) => errors.push(format!("failed to decode: {e}")), + } + + errors + } +} + +#[cfg(test)] +#[rstest::rstest] +#[case::ok(BasicConstraints { ca: true, path_len_constraint: Some(0) }, true)] +#[case::ca_false(BasicConstraints { ca: false, path_len_constraint: Some(0) }, false)] +#[case::path_none(BasicConstraints { ca: true, path_len_constraint: None }, false)] +#[case::path_too_long(BasicConstraints { ca: true, path_len_constraint: Some(1) }, false)] +#[case::both_wrong(BasicConstraints { ca: false, path_len_constraint: None }, false)] +fn test(#[case] bc: BasicConstraints, #[case] valid: bool) { + let outcome = BasicConstraintsValidator::check(bc); + assert_eq!(outcome.is_none(), valid) +} diff --git a/src/definitions/x509/validation/extensions/crl_distribution_points.rs b/src/definitions/x509/validation/extensions/crl_distribution_points.rs new file mode 100644 index 00000000..76d29544 --- /dev/null +++ b/src/definitions/x509/validation/extensions/crl_distribution_points.rs @@ -0,0 +1,177 @@ +use const_oid::AssociatedOid; +use der::Decode; +use x509_cert::ext::{ + pkix::{ + name::{DistributionPointName, GeneralName}, + CrlDistributionPoints, + }, + Extension, +}; + +use super::{Error, ExtensionValidator}; + +/// CRLDistributionPoints validation for all certificate profiles. +pub struct CrlDistributionPointsValidator; + +impl CrlDistributionPointsValidator { + fn check(crl_distribution_points: CrlDistributionPoints) -> Vec { + if crl_distribution_points.0.is_empty() { + return vec!["expected one or more distribution points".into()]; + } + let mut errors = vec![]; + for point in crl_distribution_points.0.into_iter() { + if point.crl_issuer.is_some() { + errors.push(format!( + "crl_issuer cannot be set, but is set for: {point:?}" + )) + } + + if point.reasons.is_some() { + errors.push(format!("reasons cannot be set, but is set for: {point:?}",)) + } + + if !point + .distribution_point + .as_ref() + .is_some_and(|dpn| match dpn { + DistributionPointName::FullName(names) => names + .iter() + .any(|gn| matches!(gn, GeneralName::UniformResourceIdentifier(_))), + DistributionPointName::NameRelativeToCRLIssuer(_) => false, + }) + { + errors.push(format!("point is invalid: {point:?}",)) + } + } + errors + } +} + +impl ExtensionValidator for CrlDistributionPointsValidator { + fn oid(&self) -> const_oid::ObjectIdentifier { + CrlDistributionPoints::OID + } + + fn ext_name(&self) -> &'static str { + "CrlDistributionPoints" + } + + fn validate(&self, extension: &Extension) -> Vec { + let bytes = extension.extn_value.as_bytes(); + let crl_distribution_points = CrlDistributionPoints::from_der(bytes); + match crl_distribution_points { + Ok(crl_dps) => Self::check(crl_dps), + Err(e) => vec![format!("failed to decode: {e}")], + } + } +} + +#[cfg(test)] +use der::flagset::FlagSet; +#[cfg(test)] +use x509_cert::ext::pkix::crl::dp::DistributionPoint; + +#[cfg(test)] +#[rstest::rstest] +#[case::ok( + CrlDistributionPoints( + vec![ + DistributionPoint { + distribution_point: Some( + DistributionPointName::FullName( + vec![ + GeneralName::UniformResourceIdentifier( + "http://example.com".to_string().try_into().unwrap() + ) + ] + ) + ), + reasons: None, + crl_issuer: None, + } + ] + ), + true +)] +#[case::empty(CrlDistributionPoints(vec![]), false)] +#[case::one_good_one_bad( + CrlDistributionPoints( + vec![ + DistributionPoint { + distribution_point: Some( + DistributionPointName::FullName( + vec![ + GeneralName::UniformResourceIdentifier( + "http://example.com".to_string().try_into().unwrap() + ) + ] + ) + ), + reasons: None, + crl_issuer: None, + }, + DistributionPoint { + distribution_point: None, + reasons: None, + crl_issuer: None, + } + ] + ), + false +)] +#[case::no_dp( + CrlDistributionPoints( + vec![ + DistributionPoint { + distribution_point: None, + reasons: None, + crl_issuer: None, + } + ] + ), + false +)] +#[case::good_with_reasons( + CrlDistributionPoints( + vec![ + DistributionPoint { + distribution_point: Some( + DistributionPointName::FullName( + vec![ + GeneralName::UniformResourceIdentifier( + "http://example.com".to_string().try_into().unwrap() + ) + ] + ) + ), + reasons: Some(FlagSet::default()), + crl_issuer: None, + }, + ] + ), + false +)] +#[case::good_with_issuer( + CrlDistributionPoints( + vec![ + DistributionPoint { + distribution_point: Some( + DistributionPointName::FullName( + vec![ + GeneralName::UniformResourceIdentifier( + "http://example.com".to_string().try_into().unwrap() + ) + ] + ) + ), + reasons: None, + crl_issuer: Some(vec![]), + }, + ] + ), + false +)] +fn test(#[case] crl_dps: CrlDistributionPoints, #[case] valid: bool) { + let outcome = CrlDistributionPointsValidator::check(crl_dps); + assert_eq!(outcome.is_empty(), valid) +} diff --git a/src/definitions/x509/validation/extensions/extended_key_usage.rs b/src/definitions/x509/validation/extensions/extended_key_usage.rs new file mode 100644 index 00000000..db0e38a9 --- /dev/null +++ b/src/definitions/x509/validation/extensions/extended_key_usage.rs @@ -0,0 +1,95 @@ +use const_oid::AssociatedOid; +use const_oid::ObjectIdentifier; +use der::Decode; +use x509_cert::ext::{pkix::ExtendedKeyUsage, Extension}; + +use super::Error; +use super::ExtensionValidator; + +/// ExtendedKeyUsage validation for document signer and mdoc reader certificates. +pub struct ExtendedKeyUsageValidator { + pub expected_oid: ObjectIdentifier, +} + +impl ExtendedKeyUsageValidator { + fn check(&self, eku: ExtendedKeyUsage) -> Option { + if !eku.0.iter().all(|oid| *oid == self.expected_oid) { + Some(format!( + "expected '{}', found '{:?}'", + self.expected_oid, eku.0 + )) + } else if eku.0.is_empty() { + Some(format!("expected '{}', found '[]'", self.expected_oid)) + } else { + None + } + } +} + +impl ExtensionValidator for ExtendedKeyUsageValidator { + fn oid(&self) -> const_oid::ObjectIdentifier { + ExtendedKeyUsage::OID + } + + fn ext_name(&self) -> &'static str { + "ExtendedKeyUsage" + } + + fn validate(&self, extension: &Extension) -> Vec { + let bytes = extension.extn_value.as_bytes(); + let extended_key_usage = ExtendedKeyUsage::from_der(bytes); + + if !extension.critical { + tracing::warn!("expected ExtendedKeyUsage extension to be critical",) + } + + match extended_key_usage { + Ok(eku) => { + if let Some(e) = self.check(eku) { + vec![e] + } else { + vec![] + } + } + Err(e) => { + vec![format!("failed to decode: {e}")] + } + } + } +} + +pub const fn document_signer_extended_key_usage_oid() -> ObjectIdentifier { + // Unwrap safety: unit tested. + ObjectIdentifier::new_unwrap("1.0.18013.5.1.2") +} + +pub const fn mdoc_reader_extended_key_usage_oid() -> ObjectIdentifier { + // Unwrap safety: unit tested. + ObjectIdentifier::new_unwrap("1.0.18013.5.1.6") +} + +#[cfg(test)] +#[rstest::rstest] +#[case::ok(ExtendedKeyUsage(vec![ObjectIdentifier::new_unwrap("1.1.1")]), true)] +#[case::wrong(ExtendedKeyUsage(vec![ObjectIdentifier::new_unwrap("1.1.0")]), false)] +#[case::missing(ExtendedKeyUsage(vec![]), false)] +#[case::good_and_bad(ExtendedKeyUsage(vec![ObjectIdentifier::new_unwrap("1.1.1"), ObjectIdentifier::new_unwrap("1.1.0")]), false)] +fn test(#[case] eku: ExtendedKeyUsage, #[case] valid: bool) { + let outcome = ExtendedKeyUsageValidator { + expected_oid: ObjectIdentifier::new_unwrap("1.1.1"), + } + .check(eku); + assert_eq!(outcome.is_none(), valid) +} + +#[cfg(test)] +#[test] +fn test_document_signer_extended_key_usage_oid() { + document_signer_extended_key_usage_oid(); +} + +#[cfg(test)] +#[test] +fn test_mdoc_reader_extended_key_usage_oid() { + mdoc_reader_extended_key_usage_oid(); +} diff --git a/src/definitions/x509/validation/extensions/issuer_alternative_name.rs b/src/definitions/x509/validation/extensions/issuer_alternative_name.rs new file mode 100644 index 00000000..1053aa82 --- /dev/null +++ b/src/definitions/x509/validation/extensions/issuer_alternative_name.rs @@ -0,0 +1,57 @@ +use const_oid::AssociatedOid; +use const_oid::ObjectIdentifier; +use der::Decode; +use x509_cert::ext::{ + pkix::{name::GeneralName, IssuerAltName}, + Extension, +}; + +use super::Error; +use super::ExtensionValidator; + +pub struct IssuerAlternativeNameValidator; + +impl IssuerAlternativeNameValidator { + fn check(ian: IssuerAltName) -> Option { + if !ian.0.iter().all(|gn| { + matches!( + gn, + GeneralName::Rfc822Name(_) | GeneralName::UniformResourceIdentifier(_) + ) + }) { + Some(format!( + "invalid type in found in general names: {:?}", + ian.0 + )) + } else { + None + } + } +} + +impl ExtensionValidator for IssuerAlternativeNameValidator { + fn oid(&self) -> ObjectIdentifier { + IssuerAltName::OID + } + + fn ext_name(&self) -> &'static str { + "IssuerAlternativeName" + } + + fn validate(&self, extension: &Extension) -> Vec { + let bytes = extension.extn_value.as_bytes(); + let iss_altname = IssuerAltName::from_der(bytes); + match iss_altname { + Ok(ian) => { + if let Some(e) = Self::check(ian) { + vec![e] + } else { + vec![] + } + } + Err(e) => { + vec![format!("failed to decode: {e}")] + } + } + } +} diff --git a/src/definitions/x509/validation/extensions/key_usage.rs b/src/definitions/x509/validation/extensions/key_usage.rs new file mode 100644 index 00000000..4e3c6bcc --- /dev/null +++ b/src/definitions/x509/validation/extensions/key_usage.rs @@ -0,0 +1,106 @@ +use const_oid::AssociatedOid; +use der::{flagset::FlagSet, Decode}; +use x509_cert::ext::{ + pkix::{KeyUsage, KeyUsages}, + Extension, +}; + +use super::{Error, ExtensionValidator}; + +/// KeyUsage validation for all certificate profiles. +pub struct KeyUsageValidator { + expected_flagset: FlagSet, +} + +impl KeyUsageValidator { + pub fn document_signer() -> Self { + Self { + expected_flagset: KeyUsages::DigitalSignature.into(), + } + } + + pub fn mdoc_reader() -> Self { + Self { + expected_flagset: KeyUsages::DigitalSignature.into(), + } + } + + pub fn iaca() -> Self { + Self { + expected_flagset: KeyUsages::CRLSign | KeyUsages::KeyCertSign, + } + } + + fn check(&self, ku: KeyUsage) -> Option { + if ku.0 != self.expected_flagset { + Some(format!( + "unexpected usage: {:?}", + ku.0.into_iter().collect::>() + )) + } else { + None + } + } +} + +impl ExtensionValidator for KeyUsageValidator { + fn oid(&self) -> const_oid::ObjectIdentifier { + KeyUsage::OID + } + + fn ext_name(&self) -> &'static str { + "KeyUsage" + } + + fn validate(&self, extension: &Extension) -> Vec { + let bytes = extension.extn_value.as_bytes(); + let key_usage = KeyUsage::from_der(bytes); + + if !extension.critical { + tracing::warn!("expected KeyUsage extension to be critical",) + } + + match key_usage { + Ok(ku) => { + if let Some(e) = self.check(ku) { + vec![e] + } else { + vec![] + } + } + Err(e) => { + vec![format!("failed to decode: {e}")] + } + } + } +} + +#[cfg(test)] +#[rstest::rstest] +#[case::ds_ok(KeyUsageValidator::document_signer(), KeyUsage(KeyUsages::DigitalSignature.into()), true)] +#[case::iaca_ok(KeyUsageValidator::iaca(), KeyUsage(KeyUsages::CRLSign | KeyUsages::KeyCertSign), true)] +#[case::ds_extra(KeyUsageValidator::iaca(), KeyUsage( KeyUsages::KeyCertSign | KeyUsages::DigitalSignature), false)] +#[case::iaca_extra(KeyUsageValidator::iaca(), KeyUsage(KeyUsages::CRLSign | KeyUsages::KeyCertSign | KeyUsages::DigitalSignature), false)] +#[case::ds_missing(KeyUsageValidator::iaca(), KeyUsage(FlagSet::default()), false)] +#[case::iaca_missing(KeyUsageValidator::iaca(), KeyUsage(KeyUsages::KeyCertSign.into()), false)] +fn test(#[case] kuv: KeyUsageValidator, #[case] ku: KeyUsage, #[case] valid: bool) { + let outcome = kuv.check(ku); + assert_eq!(outcome.is_none(), valid) +} + +#[cfg(test)] +#[test] +fn test_flagsets() { + assert!(KeyUsageValidator::document_signer() + .expected_flagset + .contains(KeyUsages::DigitalSignature)); + assert!(KeyUsageValidator::mdoc_reader() + .expected_flagset + .contains(KeyUsages::DigitalSignature)); + assert!(KeyUsageValidator::iaca() + .expected_flagset + .contains(KeyUsages::CRLSign)); + assert!(KeyUsageValidator::iaca() + .expected_flagset + .contains(KeyUsages::KeyCertSign)); +} diff --git a/src/definitions/x509/validation/extensions/mod.rs b/src/definitions/x509/validation/extensions/mod.rs new file mode 100644 index 00000000..e88b5947 --- /dev/null +++ b/src/definitions/x509/validation/extensions/mod.rs @@ -0,0 +1,258 @@ +//! All the checks in this module relate to requirements for X.509 certificates as detailed in +//! Annex B of ISO18013-5. Specifically, the requirements for extensions in IACA and mdoc signer +//! certificates are given in tables B.2 and B.4 respectively. + +mod basic_constraints; +mod crl_distribution_points; +mod extended_key_usage; +mod issuer_alternative_name; +mod key_usage; +mod subject_key_identifier; + +use std::ops::Deref; + +use basic_constraints::BasicConstraintsValidator; +use const_oid::db; +use const_oid::AssociatedOid; +use const_oid::ObjectIdentifier; +use crl_distribution_points::CrlDistributionPointsValidator; +use der::Decode; +use extended_key_usage::document_signer_extended_key_usage_oid; +use extended_key_usage::mdoc_reader_extended_key_usage_oid; +use extended_key_usage::ExtendedKeyUsageValidator; +use issuer_alternative_name::IssuerAlternativeNameValidator; +use key_usage::KeyUsageValidator; +use subject_key_identifier::SubjectKeyIdentifierValidator; +use x509_cert::ext::{ + pkix::{ + AuthorityKeyIdentifier, FreshestCrl, InhibitAnyPolicy, NameConstraints, PolicyConstraints, + PolicyMappings, SubjectKeyIdentifier, + }, + Extension, +}; +use x509_cert::Certificate; + +type Error = String; + +/// Validate that the subject key identifier of the issuer and the authority key identifier of the +/// subject are present and equal. +pub fn key_identifier_check<'a, E>(issuer_extensions: E, subject_extensions: E) -> bool +where + E: Iterator + Clone, +{ + let issuer_skis = issuer_extensions.filter_map(|ext| { + if ext.extn_id == SubjectKeyIdentifier::OID { + SubjectKeyIdentifier::from_der(ext.extn_value.as_bytes()) + .inspect_err(|e| tracing::warn!("failed to parse SubjectKeyIdentifier: {e}")) + .ok() + } else { + None + } + }); + + subject_extensions + .filter_map(|ext| { + if ext.extn_id == AuthorityKeyIdentifier::OID { + AuthorityKeyIdentifier::from_der(ext.extn_value.as_bytes()) + .inspect_err(|e| tracing::warn!("failed to parse AuthorityKeyIdentifier: {e}")) + .ok() + } else { + None + } + }) + .filter_map(|aki| aki.key_identifier) + .any(|ki| { + issuer_skis.clone().any(|ski| { + tracing::debug!("comparing key identifiers:\n\t{ki:?}\n\t{:?}", ski.0); + ki == ski.0 + }) + }) +} + +/// Validate IACA extensions according to 18013-5 Annex B. +pub fn validate_iaca_extensions(certificate: &Certificate) -> Vec { + tracing::debug!("validating IACA extensions..."); + + let extensions = certificate.tbs_certificate.extensions.iter().flatten(); + + let mut errors: Vec = check_for_disallowed_x509_extensions(extensions.clone()); + + errors.extend( + ExtensionValidators::default() + .with(SubjectKeyIdentifierValidator::from_certificate(certificate)) + .with(KeyUsageValidator::iaca()) + .with(BasicConstraintsValidator) + .with(CrlDistributionPointsValidator) + .with(IssuerAlternativeNameValidator) + .validate_extensions(extensions), + ); + + errors +} + +/// Validate document signer extensions according to 18013-5 Annex B. +pub fn validate_document_signer_certificate_extensions(certificate: &Certificate) -> Vec { + tracing::debug!("validating document signer certificate extensions..."); + + let extensions = certificate.tbs_certificate.extensions.iter().flatten(); + + let mut errors: Vec = check_for_disallowed_x509_extensions(extensions.clone()); + + errors.extend( + ExtensionValidators::default() + .with(SubjectKeyIdentifierValidator::from_certificate(certificate)) + .with(ExtendedKeyUsageValidator { + expected_oid: document_signer_extended_key_usage_oid(), + }) + .with(KeyUsageValidator::document_signer()) + .with(CrlDistributionPointsValidator) + .with(IssuerAlternativeNameValidator) + .validate_extensions(extensions), + ); + + errors +} + +/// Validate mdoc reader extensions according to 18013-5 Annex B. +pub fn validate_mdoc_reader_certificate_extensions(certificate: &Certificate) -> Vec { + tracing::debug!("validating mdoc_reader certificate extensions..."); + + let extensions = certificate.tbs_certificate.extensions.iter().flatten(); + + let mut errors: Vec = check_for_disallowed_x509_extensions(extensions.clone()); + + errors.extend( + ExtensionValidators::default() + .with(SubjectKeyIdentifierValidator::from_certificate(certificate)) + .with(ExtendedKeyUsageValidator { + expected_oid: mdoc_reader_extended_key_usage_oid(), + }) + .with(KeyUsageValidator::mdoc_reader()) + .with(CrlDistributionPointsValidator) + .with(IssuerAlternativeNameValidator) + .validate_extensions(extensions), + ); + + errors +} + +#[derive(Default)] +struct ExtensionValidators(Vec>); + +struct RequiredExtension { + found: bool, + validator: Box, +} + +impl RequiredExtension { + fn new(validator: Box) -> Self { + Self { + found: false, + validator, + } + } +} + +impl Deref for RequiredExtension { + type Target = Box; + + fn deref(&self) -> &Self::Target { + &self.validator + } +} + +trait ExtensionValidator { + fn oid(&self) -> ObjectIdentifier; + fn ext_name(&self) -> &'static str; + fn validate(&self, extension: &Extension) -> Vec; +} + +impl ExtensionValidators { + fn with(mut self, validator: V) -> Self { + self.0.push(Box::new(validator)); + self + } + + fn validate_extensions<'a, Extensions>(self, extensions: Extensions) -> Vec + where + Extensions: IntoIterator, + { + let mut validation_errors = vec![]; + + let mut validators: Vec = + self.0.into_iter().map(RequiredExtension::new).collect(); + + for ext in extensions { + if let Some(validator) = validators.iter_mut().find(|validator| { + tracing::debug!("searching for ext: '{}'", ext.extn_id); + validator.oid() == ext.extn_id + }) { + tracing::debug!("validating required extension: {}", ext.extn_id); + validation_errors.extend( + validator + .validate(ext) + .into_iter() + .map(|e| format!("{}: {e}", validator.ext_name())), + ); + validator.found = true; + } else if ext.critical { + tracing::debug!( + "critical, non-required extension causing an error: {}", + ext.extn_id + ); + validation_errors.push(format!( + "contains unknown critical extension: {}", + ext.extn_id + )); + } else { + tracing::debug!("non-critical, non-required extension ignored: {ext:?}") + } + } + + validation_errors.extend( + validators + .iter() + .filter(|v| !v.found) + .map(|v| format!("{}: required extension not found", v.ext_name())), + ); + + validation_errors + } +} + +/// As identified in 18013-5 Annex B, section B.1.1. +/// +/// The specification is unclear as to which certificates this restriction applies to, so it is +/// assumed in this library to apply to all and only to certificate profiles defined in Annex B. +fn check_for_disallowed_x509_extensions<'a, E>(extensions: E) -> Vec +where + E: Iterator + Clone, +{ + let disallowed_extensions = [ + PolicyMappings::OID, + NameConstraints::OID, + PolicyConstraints::OID, + InhibitAnyPolicy::OID, + FreshestCrl::OID, + ]; + + extensions + .map(|e| e.extn_id) + .filter_map(|id| { + if disallowed_extensions + .iter() + .any(|disallowed_id| *disallowed_id == id) + { + Some(format!( + "extension is not allowed: {}", + db::DB + .by_oid(&id) + .map(|s| s.to_string()) + .unwrap_or(id.to_string()) + )) + } else { + None + } + }) + .collect() +} diff --git a/src/definitions/x509/validation/extensions/subject_key_identifier.rs b/src/definitions/x509/validation/extensions/subject_key_identifier.rs new file mode 100644 index 00000000..ba4d8934 --- /dev/null +++ b/src/definitions/x509/validation/extensions/subject_key_identifier.rs @@ -0,0 +1,100 @@ +use const_oid::{AssociatedOid, ObjectIdentifier}; +use der::Decode; +use sha1::{Digest, Sha1}; +use x509_cert::{ + ext::{pkix::SubjectKeyIdentifier, Extension}, + Certificate, +}; + +use super::Error; +use super::ExtensionValidator; + +pub struct SubjectKeyIdentifierValidator { + subject_public_key_bitstring_raw_bytes: Vec, +} + +impl SubjectKeyIdentifierValidator { + pub fn from_certificate(certificate: &Certificate) -> Self { + Self { + subject_public_key_bitstring_raw_bytes: certificate + .tbs_certificate + .subject_public_key_info + .subject_public_key + .raw_bytes() + .to_owned(), + } + } + + fn check(&self, ski: SubjectKeyIdentifier) -> Option { + let expected_digest = ski.0.as_bytes(); + let digest = Sha1::digest(&self.subject_public_key_bitstring_raw_bytes); + + if digest.as_slice() != expected_digest { + Some("public key digest did not match the expected value".into()) + } else { + None + } + } +} + +impl ExtensionValidator for SubjectKeyIdentifierValidator { + fn oid(&self) -> ObjectIdentifier { + SubjectKeyIdentifier::OID + } + + fn ext_name(&self) -> &'static str { + "SubjectKeyIdentifier" + } + + fn validate(&self, extension: &Extension) -> Vec { + let bytes = extension.extn_value.as_bytes(); + let ski = SubjectKeyIdentifier::from_der(bytes); + match ski { + Ok(ski) => { + if let Some(e) = self.check(ski) { + vec![e] + } else { + vec![] + } + } + Err(e) => { + vec![format!("failed to decode: {e}")] + } + } + } +} + +#[cfg(test)] +#[rstest::rstest] +#[case::ok( + include_str!("../../../../../test/issuance/256-cert.pem"), + include_str!("../../../../../test/issuance/256-cert.pem"), + true +)] +#[case::different_cert_ski_ext( + include_str!("../../../../../test/issuance/256-cert.pem"), + include_str!("../../../../../test/issuance/384-cert.pem"), + false +)] +fn test( + #[case] public_key_from_certificate_pem: &'static str, + #[case] ski_ext_from_certificate_pem: &'static str, + #[case] valid: bool, +) { + use der::DecodePem; + + let certificate = Certificate::from_pem(public_key_from_certificate_pem).unwrap(); + let skiv = SubjectKeyIdentifierValidator::from_certificate(&certificate); + + let certificate = Certificate::from_pem(ski_ext_from_certificate_pem).unwrap(); + let outcome = skiv.validate( + certificate + .tbs_certificate + .extensions + .iter() + .flatten() + .find(|ext| ext.extn_id == skiv.oid()) + .unwrap(), + ); + assert_eq!(outcome.is_empty(), valid) +} diff --git a/src/definitions/x509/validation/mod.rs b/src/definitions/x509/validation/mod.rs new file mode 100644 index 00000000..df2d1db5 --- /dev/null +++ b/src/definitions/x509/validation/mod.rs @@ -0,0 +1,218 @@ +use const_oid::db::rfc2256::STATE_OR_PROVINCE_NAME; +use error::ErrorWithContext; +use extensions::{ + key_identifier_check, validate_document_signer_certificate_extensions, + validate_iaca_extensions, validate_mdoc_reader_certificate_extensions, +}; +use names::{country_name_matches, has_rdn, state_or_province_name_matches}; +use serde::Serialize; +use signature::issuer_signed_subject; +use validity::check_validity_period; +use x509_cert::Certificate; + +use super::{ + trust_anchor::{TrustAnchorRegistry, TrustPurpose}, + X5Chain, +}; + +mod error; +mod extensions; +mod names; +pub(super) mod signature; +mod validity; + +/// Ruleset for X5Chain validation. +#[derive(Debug, Clone, Copy)] +pub enum ValidationRuleset { + /// Validate the certificate chain according to the 18013-5 rules for mDL IACA and Document + /// Signer certificates. + Mdl, + /// Validate the certificate chain according to the AAMVA rules for mDL IACA and Document + /// Signer certificates. + AamvaMdl, + /// Validate the certificate chain according to the 18013-5 rules for mDL Reader certificates. + /// + /// Only validates the leaf certificate in the x5chain against the trust anchor registry. + MdlReaderOneStep, +} + +#[derive(Debug, Clone, Serialize, Default)] +pub struct ValidationOutcome { + pub errors: Vec, +} + +impl ValidationOutcome { + pub fn success(&self) -> bool { + self.errors.is_empty() + } +} + +impl ValidationRuleset { + pub fn validate( + self, + x5chain: &X5Chain, + trust_anchors: &TrustAnchorRegistry, + ) -> ValidationOutcome { + match self { + Self::Mdl => mdl_validate(x5chain, trust_anchors), + Self::AamvaMdl => aamva_mdl_validate(x5chain, trust_anchors), + Self::MdlReaderOneStep => mdl_reader_one_step_validate(x5chain, trust_anchors), + } + } +} + +fn mdl_validate_inner<'a: 'b, 'b>( + x5chain: &'a X5Chain, + trust_anchors: &'b TrustAnchorRegistry, +) -> Result<(ValidationOutcome, &'a Certificate, &'b Certificate), ValidationOutcome> { + let mut outcome = ValidationOutcome::default(); + + // As we are validating using the IACA rules in 18013-5, we don't need to verify the whole + // chain. We can simply take the first certificate in the chain as the document signer + // certificate (NOTE 1 in B.1.1). + let document_signer = x5chain.end_entity_certificate(); + + let validity_errors = check_validity_period(document_signer) + .into_iter() + .map(ErrorWithContext::ds); + outcome.errors.extend(validity_errors); + + let ds_extension_errors = validate_document_signer_certificate_extensions(document_signer) + .into_iter() + .map(ErrorWithContext::ds); + outcome.errors.extend(ds_extension_errors); + + let mut trust_anchor_candidates = + find_trust_anchor_candidates(document_signer, trust_anchors, TrustPurpose::Iaca); + + let Some(iaca) = trust_anchor_candidates.next() else { + outcome + .errors + .push(ErrorWithContext::iaca("no valid trust anchor found")); + return Err(outcome); + }; + + if trust_anchor_candidates.next().is_some() { + tracing::warn!("more than one trust anchor candidate found, using the first one"); + } + + if let Some(error) = country_name_matches(document_signer, iaca) { + outcome.errors.push(ErrorWithContext::comparison(error)) + } + + let iaca_extension_errors = validate_iaca_extensions(iaca) + .into_iter() + .map(ErrorWithContext::iaca); + outcome.errors.extend(iaca_extension_errors); + + // TODO: CRL check on DS and IACA. + + Ok((outcome, document_signer, iaca)) +} + +fn mdl_validate(x5chain: &X5Chain, trust_anchors: &TrustAnchorRegistry) -> ValidationOutcome { + match mdl_validate_inner(x5chain, trust_anchors) { + Ok((mut outcome, ds, iaca)) => { + if has_rdn(ds, STATE_OR_PROVINCE_NAME) || has_rdn(iaca, STATE_OR_PROVINCE_NAME) { + if let Some(error) = state_or_province_name_matches(ds, iaca) { + outcome.errors.push(ErrorWithContext::comparison(error)) + } + } + + outcome + } + Err(outcome) => outcome, + } +} + +fn aamva_mdl_validate(x5chain: &X5Chain, trust_anchors: &TrustAnchorRegistry) -> ValidationOutcome { + match mdl_validate_inner(x5chain, trust_anchors) { + Ok((mut outcome, ds, iaca)) => { + if let Some(error) = state_or_province_name_matches(ds, iaca) { + outcome.errors.push(ErrorWithContext::comparison(error)) + } + + outcome + } + Err(outcome) => outcome, + } +} + +fn mdl_reader_one_step_validate( + x5chain: &X5Chain, + trust_anchors: &TrustAnchorRegistry, +) -> ValidationOutcome { + let mut outcome = ValidationOutcome::default(); + + let reader = x5chain.end_entity_certificate(); + + let validity_errors = check_validity_period(reader) + .into_iter() + .map(ErrorWithContext::reader); + outcome.errors.extend(validity_errors); + + let reader_extension_errors = validate_mdoc_reader_certificate_extensions(reader) + .into_iter() + .map(ErrorWithContext::reader); + outcome.errors.extend(reader_extension_errors); + + let mut trust_anchor_candidates = + find_trust_anchor_candidates(reader, trust_anchors, TrustPurpose::ReaderCa); + + let Some(_reader_ca) = trust_anchor_candidates.next() else { + outcome + .errors + .push(ErrorWithContext::reader_ca("no valid trust anchor found")); + return outcome; + }; + + if trust_anchor_candidates.next().is_some() { + tracing::warn!("more than one trust anchor candidate found, using the first one"); + } + + // TODO: CRL or OCSP check on reader and reader CA. + + outcome +} + +fn find_trust_anchor_candidates<'a: 'b, 'b>( + subject: &'a Certificate, + trust_anchors: &'b TrustAnchorRegistry, + trust_purpose: TrustPurpose, +) -> impl Iterator { + trust_anchors + .anchors + .iter() + .filter_map(move |anchor| { + if trust_purpose == anchor.purpose { + Some(&anchor.certificate) + } else { + None + } + }) + .filter(|candidate| candidate.tbs_certificate.subject == subject.tbs_certificate.issuer) + .filter(|candidate| { + let valid = key_identifier_check( + candidate.tbs_certificate.extensions.iter().flatten(), + subject.tbs_certificate.extensions.iter().flatten(), + ); + if !valid { + tracing::warn!("key identifier extensions did not match"); + } + valid + }) + .filter(|candidate| { + let valid = issuer_signed_subject(subject, candidate); + if !valid { + tracing::warn!("issuer did not sign subject"); + } + valid + }) + .filter(|candidate| { + let errors = check_validity_period(candidate); + if !errors.is_empty() { + tracing::warn!("certificate is not valid: {errors:?}"); + } + errors.is_empty() + }) +} diff --git a/src/definitions/x509/validation/names.rs b/src/definitions/x509/validation/names.rs new file mode 100644 index 00000000..5a51abb2 --- /dev/null +++ b/src/definitions/x509/validation/names.rs @@ -0,0 +1,154 @@ +use const_oid::{ + db::{self, rfc2256::STATE_OR_PROVINCE_NAME, rfc4519::COUNTRY_NAME}, + ObjectIdentifier, +}; +use x509_cert::{attr::AttributeValue, Certificate}; + +use crate::definitions::x509::util::{attribute_value_to_str, common_name_or_unknown}; + +#[derive(Debug, Copy, Clone, thiserror::Error)] +pub enum Error<'l> { + #[error("'{certificate_common_name}' has no subject '{name}'")] + Missing { + certificate_common_name: &'l str, + name: &'static str, + }, + #[error("'{certificate_common_name}' has multiple subject '{name}'s")] + Multiple { + certificate_common_name: &'l str, + name: &'static str, + }, + #[error("subject '{name}' does not match: {this} != {that}")] + Mismatch { + name: &'static str, + this: &'l str, + that: &'l str, + }, +} + +#[allow(dead_code)] +/// Checks that countryName in the certificate has only one countryName in the subject and that it +/// matches the expected value. +pub fn country_name_is<'a: 'c, 'b: 'c, 'c>( + certificate: &'a Certificate, + expected_country_name: &'b str, +) -> Option> { + let name = "countryName"; + + let mut cs = get_rdns(certificate, COUNTRY_NAME); + + let Some(c) = cs.next() else { + return Some(Error::Missing { + certificate_common_name: common_name_or_unknown(certificate), + name, + }); + }; + + if cs.next().is_some() { + return Some(Error::Multiple { + certificate_common_name: common_name_or_unknown(certificate), + name, + }); + } + + let c = attribute_value_to_str(c).unwrap_or("unknown"); + + if c != expected_country_name { + return Some(Error::Mismatch { + name, + this: c, + that: expected_country_name, + }); + } + + None +} + +/// Checks that each certificate has only one countryName in the subject, and that they match. +pub fn country_name_matches<'a: 'c, 'b: 'c, 'c>( + this: &'a Certificate, + that: &'b Certificate, +) -> Option> { + name_matches(COUNTRY_NAME, this, that) +} + +/// Checks that each certificate has only one stateOrProvinceName in the subject, and that they match. +pub fn state_or_province_name_matches<'a: 'c, 'b: 'c, 'c>( + this: &'a Certificate, + that: &'b Certificate, +) -> Option> { + name_matches(STATE_OR_PROVINCE_NAME, this, that) +} + +fn name_matches<'a: 'c, 'b: 'c, 'c>( + name_oid: ObjectIdentifier, + this: &'a Certificate, + that: &'b Certificate, +) -> Option> { + let name = db::DB.by_oid(&name_oid).unwrap_or("unknown"); + + let mut this_cs = get_rdns(this, name_oid); + let mut that_cs = get_rdns(that, name_oid); + + let Some(this_c) = this_cs.next() else { + return Some(Error::Missing { + certificate_common_name: common_name_or_unknown(this), + name, + }); + }; + + let Some(that_c) = that_cs.next() else { + return Some(Error::Missing { + certificate_common_name: common_name_or_unknown(that), + name, + }); + }; + + if this_cs.next().is_some() { + return Some(Error::Multiple { + certificate_common_name: common_name_or_unknown(that), + name, + }); + } + + if that_cs.next().is_some() { + return Some(Error::Multiple { + certificate_common_name: common_name_or_unknown(this), + name, + }); + } + + if this_c != that_c { + return Some(Error::Mismatch { + name, + this: attribute_value_to_str(this_c).unwrap_or("Unknown"), + that: attribute_value_to_str(that_c).unwrap_or("Unknown"), + }); + } + + None +} + +/// Check whether the certificate has a particular RelativeDistinguished name in the subject. +pub fn has_rdn(certificate: &Certificate, oid: ObjectIdentifier) -> bool { + get_rdns(certificate, oid).next().is_some() +} + +fn get_rdns( + certificate: &Certificate, + oid: ObjectIdentifier, +) -> impl Iterator { + certificate + .tbs_certificate + .subject + .0 + .iter() + .flat_map(|rdn| rdn.0.iter()) + .filter_map(move |attribute| { + if attribute.oid == oid { + Some(&attribute.value) + } else { + None + } + }) +} diff --git a/src/definitions/x509/validation/signature.rs b/src/definitions/x509/validation/signature.rs new file mode 100644 index 00000000..8af3d8a0 --- /dev/null +++ b/src/definitions/x509/validation/signature.rs @@ -0,0 +1,69 @@ +use der::Encode; +use ecdsa::{signature::Verifier, Signature, VerifyingKey}; +use p256::NistP256; +use x509_cert::Certificate; + +use crate::definitions::x509::util::public_key; + +/// Check that the issuer certificate signed the subject certificate. +pub fn issuer_signed_subject(subject: &Certificate, issuer: &Certificate) -> bool { + // TODO: Support curves other than P-256. + let issuer_public_key: VerifyingKey = match public_key(issuer) { + Ok(pk) => pk, + Err(e) => { + tracing::error!("failed to decode issuer public key: {e:?}"); + return false; + } + }; + + let sig: Signature = match Signature::from_der(subject.signature.raw_bytes()) { + Ok(sig) => sig, + Err(e) => { + tracing::error!("failed to parse subject signature: {e:?}"); + return false; + } + }; + + let tbs = match subject.tbs_certificate.to_der() { + Ok(tbs) => tbs, + Err(e) => { + tracing::error!("failed to parse subject tbs: {e:?}"); + return false; + } + }; + + match issuer_public_key.verify(&tbs, &sig) { + Ok(()) => true, + Err(e) => { + tracing::info!("subject certificate signature could not be validated: {e:?}"); + false + } + } +} + +#[cfg(test)] +mod test { + use crate::definitions::x509::x5chain::CertificateWithDer; + + use super::issuer_signed_subject; + + #[test] + pub fn correct_signature() { + let target = include_bytes!("../../../../test/presentation/isomdl_iaca_signer.pem"); + let issuer = include_bytes!("../../../../test/presentation/isomdl_iaca_root_cert.pem"); + assert!(issuer_signed_subject( + &CertificateWithDer::from_pem(target).unwrap().inner, + &CertificateWithDer::from_pem(issuer).unwrap().inner, + )) + } + + #[test] + pub fn incorrect_signature() { + let issuer = include_bytes!("../../../../test/presentation/isomdl_iaca_signer.pem"); + let target = include_bytes!("../../../../test/presentation/isomdl_iaca_root_cert.pem"); + assert!(!issuer_signed_subject( + &CertificateWithDer::from_pem(target).unwrap().inner, + &CertificateWithDer::from_pem(issuer).unwrap().inner, + )) + } +} diff --git a/src/definitions/x509/validation/validity.rs b/src/definitions/x509/validation/validity.rs new file mode 100644 index 00000000..fc4629c5 --- /dev/null +++ b/src/definitions/x509/validation/validity.rs @@ -0,0 +1,27 @@ +use time::OffsetDateTime; +use x509_cert::Certificate; + +pub fn check_validity_period(certificate: &Certificate) -> Vec { + let validity = certificate.tbs_certificate.validity; + let mut errors: Vec = vec![]; + if validity.not_after.to_unix_duration().as_secs() + < OffsetDateTime::now_utc().unix_timestamp() as u64 + { + errors.push(Error::Expired); + }; + if validity.not_before.to_unix_duration().as_secs() + > OffsetDateTime::now_utc().unix_timestamp() as u64 + { + errors.push(Error::NotYetValid); + }; + + errors +} + +#[derive(Debug, Clone, Copy, thiserror::Error)] +pub enum Error { + #[error("expired")] + Expired, + #[error("not yet valid")] + NotYetValid, +} diff --git a/src/definitions/x509/x5chain.rs b/src/definitions/x509/x5chain.rs new file mode 100644 index 00000000..d3f44bc0 --- /dev/null +++ b/src/definitions/x509/x5chain.rs @@ -0,0 +1,200 @@ +use std::io::Read; + +use crate::definitions::helpers::NonEmptyVec; + +use anyhow::{anyhow, bail, Context, Error, Result}; + +use const_oid::AssociatedOid; + +use ciborium::Value as CborValue; +use ecdsa::{PrimeCurve, VerifyingKey}; +use elliptic_curve::{ + sec1::{FromEncodedPoint, ModulusSize, ToEncodedPoint}, + AffinePoint, CurveArithmetic, FieldBytesSize, +}; +use x509_cert::der::Encode; +use x509_cert::{certificate::Certificate, der::Decode}; + +use super::util::{common_name_or_unknown, public_key}; + +/// See: +pub const X5CHAIN_COSE_HEADER_LABEL: i64 = 0x21; + +/// X.509 certificate with the DER representation held in memory for ease of serialization. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct CertificateWithDer { + pub inner: Certificate, + der: Vec, +} + +impl CertificateWithDer { + pub fn from_pem(bytes: &[u8]) -> Result { + let bytes = pem_rfc7468::decode_vec(bytes) + .map_err(|e| anyhow!("unable to parse certificate from PEM encoding: {e}"))? + .1; + CertificateWithDer::from_der(&bytes) + } + + pub fn from_der(bytes: &[u8]) -> Result { + let inner = Certificate::from_der(bytes) + .context("unable to parse certificate from DER encoding")?; + Ok(Self { + inner, + der: bytes.to_vec(), + }) + } + + pub fn from_cert(certificate: Certificate) -> Result { + let der = certificate.to_der()?; + Ok(Self { + inner: certificate, + der, + }) + } +} + +#[derive(Debug, Clone)] +pub struct X5Chain(NonEmptyVec); + +impl From> for X5Chain { + fn from(v: NonEmptyVec) -> Self { + Self(v) + } +} + +impl X5Chain { + pub fn builder() -> Builder { + Builder::default() + } + + pub fn into_cbor(&self) -> CborValue { + match &self.0.as_ref() { + &[cert] => CborValue::Bytes(cert.der.clone()), + certs => CborValue::Array( + certs + .iter() + .map(|x509| x509.der.clone()) + .map(CborValue::Bytes) + .collect::>(), + ), + } + } + + pub fn from_cbor(cbor: CborValue) -> Result { + match cbor { + CborValue::Bytes(bytes) => { + Self::builder().with_der_certificate(&bytes)?.build() + }, + CborValue::Array(x509s) => { + x509s.iter() + .try_fold(Self::builder(), |mut builder, x509| match x509 { + CborValue::Bytes(bytes) => { + builder = builder.with_der_certificate(bytes)?; + Ok(builder) + }, + _ => bail!("expected x509 certificate in the x5chain to be a cbor encoded bytestring, but received: {x509:?}") + })? + .build() + }, + _ => bail!("expected x5chain to be a cbor encoded bytestring or array, but received: {cbor:?}") + } + } + + /// Retrieve the end-entity certificate. + pub fn end_entity_certificate(&self) -> &Certificate { + &self.0[0].inner + } + + /// Retrieve the public key of the end-entity certificate. + pub fn end_entity_public_key(&self) -> Result, Error> + where + C: AssociatedOid + CurveArithmetic + PrimeCurve, + AffinePoint: FromEncodedPoint + ToEncodedPoint, + FieldBytesSize: ModulusSize, + { + public_key(self.end_entity_certificate()) + } + + /// Retrieve the public key of the end-entity certificate. + pub fn end_entity_common_name(&self) -> &str { + common_name_or_unknown(self.end_entity_certificate()) + } +} + +#[derive(Default, Debug, Clone)] +pub struct Builder { + certs: Vec, +} + +impl Builder { + pub fn with_certificate(mut self, cert: Certificate) -> Result { + let x509 = CertificateWithDer::from_cert(cert)?; + self.certs.push(x509); + Ok(self) + } + pub fn with_certificate_and_der(mut self, x509: CertificateWithDer) -> Builder { + self.certs.push(x509); + self + } + pub fn with_pem_certificate(mut self, data: &[u8]) -> Result { + let x509 = CertificateWithDer::from_pem(data)?; + self.certs.push(x509); + Ok(self) + } + pub fn with_der_certificate(mut self, data: &[u8]) -> Result { + let x509 = CertificateWithDer::from_der(data)?; + self.certs.push(x509); + Ok(self) + } + pub fn with_pem_certificate_from_io(self, mut io: R) -> Result { + let mut data: Vec = vec![]; + io.read_to_end(&mut data)?; + self.with_pem_certificate(&data) + } + pub fn with_der_certificate_from_io(self, mut io: R) -> Result { + let mut data: Vec = vec![]; + io.read_to_end(&mut data)?; + self.with_der_certificate(&data) + } + pub fn build(self) -> Result { + Ok(X5Chain(self.certs.try_into().context( + "at least one certificate must be given to the builder", + )?)) + } +} + +#[cfg(test)] +pub mod test { + use super::*; + + static CERT_256: &[u8] = include_bytes!("../../../test/issuance/256-cert.pem"); + static CERT_384: &[u8] = include_bytes!("../../../test/issuance/384-cert.pem"); + static CERT_521: &[u8] = include_bytes!("../../../test/issuance/521-cert.pem"); + + #[test] + pub fn self_signed_es256() { + let _x5chain = X5Chain::builder() + .with_pem_certificate(CERT_256) + .expect("unable to add cert") + .build() + .expect("unable to build x5chain"); + } + + #[test] + pub fn self_signed_es384() { + let _x5chain = X5Chain::builder() + .with_pem_certificate(CERT_384) + .expect("unable to add cert") + .build() + .expect("unable to build x5chain"); + } + + #[test] + pub fn self_signed_es512() { + let _x5chain = X5Chain::builder() + .with_pem_certificate(CERT_521) + .expect("unable to add cert") + .build() + .expect("unable to build x5chain"); + } +} diff --git a/src/issuance/mdoc.rs b/src/issuance/mdoc.rs index 737191af..9feb1451 100644 --- a/src/issuance/mdoc.rs +++ b/src/issuance/mdoc.rs @@ -12,12 +12,12 @@ use signature::{SignatureEncoding, Signer}; use crate::cose::sign1::PreparedCoseSign1; use crate::cose::{MaybeTagged, SignatureAlgorithm}; use crate::{ + definitions::x509::x5chain::{X5Chain, X5CHAIN_COSE_HEADER_LABEL}, definitions::{ helpers::{NonEmptyMap, NonEmptyVec, Tag24}, issuer_signed::{IssuerNamespaces, IssuerSignedItemBytes}, DeviceKeyInfo, DigestAlgorithm, DigestId, DigestIds, IssuerSignedItem, Mso, ValidityInfo, }, - issuance::x5chain::{X5Chain, X5CHAIN_HEADER_LABEL}, }; pub type Namespaces = BTreeMap>; @@ -196,7 +196,7 @@ impl PreparedMdoc { .inner .unprotected .rest - .push((Label::Int(X5CHAIN_HEADER_LABEL as i64), x5chain.into_cbor())); + .push((Label::Int(X5CHAIN_COSE_HEADER_LABEL), x5chain.into_cbor())); Mdoc { doc_type, mso, @@ -629,7 +629,7 @@ pub mod test { let mdoc_builder = minimal_test_mdoc_builder(); let x5chain = X5Chain::builder() - .with_pem(ISSUER_CERT) + .with_pem_certificate(ISSUER_CERT) .unwrap() .build() .unwrap(); @@ -646,7 +646,7 @@ pub mod test { fn decoy_digests() { let mdoc_builder = minimal_test_mdoc_builder(); let x5chain = X5Chain::builder() - .with_pem(ISSUER_CERT) + .with_pem_certificate(ISSUER_CERT) .unwrap() .build() .unwrap(); diff --git a/src/issuance/mod.rs b/src/issuance/mod.rs index 263b8012..5f25e8d2 100644 --- a/src/issuance/mod.rs +++ b/src/issuance/mod.rs @@ -2,7 +2,5 @@ //! //! The `issuance` module provides functionality for handling issuance related operations. pub mod mdoc; -pub mod x5chain; pub use mdoc::{Mdoc, Namespaces}; -pub use x5chain::{Builder, X5Chain}; diff --git a/src/issuance/x5chain.rs b/src/issuance/x5chain.rs deleted file mode 100644 index e68e55d2..00000000 --- a/src/issuance/x5chain.rs +++ /dev/null @@ -1,306 +0,0 @@ -//! This module provides functionality for working with `X.509`` certificate chains. -//! -//! The [X5Chain] struct represents a chain of `X.509`` certificates. It can be built using -//! the [Builder] struct, which allows adding certificates in either `PEM`` or `DER`` format. -//! The resulting [X5Chain] can be converted to `CBOR`` format using the [X5Chain::into_cbor] method. -//! -//! # Examples -//! -//! ```ignore -//! use crate::isomdl::issuance::x5chain::{X5Chain, Builder}; -//! -//! // Create an X5Chain using the Builder -//! let pem_data = include_bytes!("../../test/issuance/256-cert.pem"); -//! let x5chain = X5Chain::builder() -//! .with_pem(&pem_data) -//! .expect("Failed to add certificate") -//! .build() -//! .expect("Failed to build X5Chain"); -//! -//! // Convert the X5Chain to CBOR format -//! let cbor_value = x5chain.into_cbor(); -//! ``` -//! -//! The [Builder] struct provides methods for adding certificates to the chain. Certificates can be added -//! either from PEM or DER data, or from files containing PEM or DER data. -//! -//! # Examples -//! -//! ```ignore -//! use std::fs::File; -//! use crate::isomdl::issuance::x5chain::Builder; -//! -//! // Create a Builder and add a certificate from PEM data -//! let pem_data = include_bytes!("../../test/issuance/256-cert.pem"); -//! let builder = Builder::default() -//! .with_pem(pem_data) -//! .expect("Failed to add certificate"); -//! -//! // Add a certificate from DER data -//! let der_data = include_bytes!("../../test/issuance/256-cert.der"); -//! let builder = builder.with_der(der_data) -//! .expect("Failed to add certificate"); -//! -//! // Add a certificate from a PEM file -//! let pem_file = File::open("256-cert.pem").unwrap(); -//! let builder = builder.with_pem_from_file(pem_file) -//! .expect("Failed to add certificate"); -//! -//! // Add a certificate from a DER file -//! let der_file = File::open("256-cert.der").unwrap(); -//! let builder = builder.with_der_from_file(der_file) -//! .expect("Failed to add certificate"); -//! -//! // Build the X5Chain -//! let x5chain = builder.build() -//! .expect("Failed to build X5Chain"); -//! ``` -//! -//! The [X5Chain] struct also provides a [X5Chain::builder] method for creating a new [Builder] instance. -use crate::definitions::helpers::NonEmptyVec; -use anyhow::{anyhow, Result}; -use std::{fs::File, io::Read}; -use x509_cert::{ - certificate::Certificate, - der::{Decode, Encode}, -}; - -pub const X5CHAIN_HEADER_LABEL: i128 = 33; - -/// Represents an X509 certificate. -#[derive(Debug, Clone)] -pub struct X509 { - bytes: Vec, -} - -/// Represents a chain of [X509] certificates. -#[derive(Debug, Clone)] -pub struct X5Chain(NonEmptyVec); - -impl From> for X5Chain { - fn from(v: NonEmptyVec) -> Self { - Self(v) - } -} - -/// Implements the [X5Chain] struct. -/// -/// This struct provides methods for building and converting the X5Chain object. -impl X5Chain { - /// Creates a new [Builder] instance for [X5Chain]. - pub fn builder() -> Builder { - Builder::default() - } - - /// Converts the [X5Chain] object into a [`ciborium::Value`]. - pub fn into_cbor(self) -> ciborium::Value { - match &self.0.as_ref() { - &[cert] => ciborium::Value::Bytes(cert.bytes.clone()), - certs => ciborium::Value::Array( - certs - .iter() - .cloned() - .map(|x509| x509.bytes) - .map(ciborium::Value::Bytes) - .collect::>(), - ), - } - } -} - -/// Builder for creating an [X5Chain]. -/// -/// This struct is used to build an [X5Chain] by providing a vector of [X509] certificates. -/// The [X5Chain] represents a chain of `X.509`` certificates used for issuance. -/// -/// # Note -/// -/// The `Builder` struct is typically used in the context of the `issuance` module. -#[derive(Default, Debug, Clone)] -pub struct Builder { - certs: Vec, -} - -impl Builder { - /// Adds a `PEM-encoded`` certificate to the builder. - /// - /// # Errors - /// - /// Returns an error if the `PEM`` cannot be parsed or the certificate - /// cannot be converted to bytes. - /// - /// # Returns - /// - /// Returns a [Result] containing the updated [Builder] if successful. - pub fn with_pem(mut self, data: &[u8]) -> Result { - let bytes = pem_rfc7468::decode_vec(data) - .map_err(|e| anyhow!("unable to parse pem: {}", e))? - .1; - let cert: Certificate = Certificate::from_der(&bytes) - .map_err(|e| anyhow!("unable to parse certificate from der: {}", e))?; - let x509 = X509 { - bytes: cert - .to_vec() - .map_err(|e| anyhow!("unable to convert certificate to bytes: {}", e))?, - }; - self.certs.push(x509); - Ok(self) - } - - /// Adds a `DER`-encoded certificate to the builder. - /// - /// # Errors - /// - /// Returns an error if the certificate cannot be parsed from `DER` encoding - /// or cannot be converted to bytes. - /// - /// # Returns - /// - /// Returns a [Result] containing the updated [Builder] if successful. - pub fn with_der(mut self, data: &[u8]) -> Result { - let cert: Certificate = Certificate::from_der(data) - .map_err(|e| anyhow!("unable to parse certificate from der encoding: {}", e))?; - let x509 = X509 { - bytes: cert - .to_vec() - .map_err(|e| anyhow!("unable to convert certificate to bytes: {}", e))?, - }; - self.certs.push(x509); - Ok(self) - } - - /// Adds a `PEM`-encoded certificate from a file to the builder. - /// - /// # Errors - /// - /// Returns an error if the file cannot be read or the certificate cannot be parsed or converted to bytes. - /// - /// # Returns - /// - /// Returns a [Result] containing the updated [Builder] if successful. - pub fn with_pem_from_file(self, mut f: File) -> Result { - let mut data: Vec = vec![]; - f.read_to_end(&mut data)?; - self.with_pem(&data) - } - - /// Adds a `DER`-encoded certificate from a file to the builder. - /// - /// # Errors - /// - /// Returns an error if the file cannot be read or the certificate cannot be parsed or converted to bytes. - /// - /// # Returns - /// - /// Returns a [Result] containing the updated [Builder] if successful. - pub fn with_der_from_file(self, mut f: File) -> Result { - let mut data: Vec = vec![]; - f.read_to_end(&mut data)?; - self.with_der(&data) - } - - /// Builds the [X5Chain] from the added certificates. - /// - /// # Errors - /// - /// Returns an error if at least one certificate is not added to the builder. - /// - /// # Returns - /// - /// Returns a [Result] containing the built [X5Chain] if successful. - pub fn build(self) -> Result { - Ok(X5Chain(self.certs.try_into().map_err(|_| { - anyhow!("at least one certificate must be given to the builder") - })?)) - } -} - -#[cfg(test)] -pub mod test { - use super::*; - - static CERT_256: &[u8] = include_bytes!("../../test/issuance/256-cert.pem"); - static CERT_384: &[u8] = include_bytes!("../../test/issuance/384-cert.pem"); - static CERT_521: &[u8] = include_bytes!("../../test/issuance/521-cert.pem"); - - #[test] - pub fn self_signed_es256() { - let _x5chain = X5Chain::builder() - .with_pem(CERT_256) - .expect("unable to add cert") - .build() - .expect("unable to build x5chain"); - - //let self_signed = &x5chain[0]; - - //assert!(self_signed.issued(self_signed) == CertificateVerifyResult::OK); - //assert!(self_signed - // .verify( - // &self_signed - // .public_key() - // .expect("unable to get public key of cert") - // ) - // .expect("unable to verify public key of cert")); - - //assert!(matches!( - // x5chain - // .key_algorithm() - // .expect("unable to retrieve public key algorithm"), - // Algorithm::ES256 - //)); - } - - #[test] - pub fn self_signed_es384() { - let _x5chain = X5Chain::builder() - .with_pem(CERT_384) - .expect("unable to add cert") - .build() - .expect("unable to build x5chain"); - - //let self_signed = &x5chain[0]; - - //assert!(self_signed.issued(self_signed) == CertificateVerifyResult::OK); - //assert!(self_signed - // .verify( - // &self_signed - // .public_key() - // .expect("unable to get public key of cert") - // ) - // .expect("unable to verify public key of cert")); - - //assert!(matches!( - // x5chain - // .key_algorithm() - // .expect("unable to retrieve public key algorithm"), - // Algorithm::ES384 - //)); - } - - #[test] - pub fn self_signed_es512() { - let _x5chain = X5Chain::builder() - .with_pem(CERT_521) - .expect("unable to add cert") - .build() - .expect("unable to build x5chain"); - - //let self_signed = &x5chain[0]; - - //assert!(self_signed.issued(self_signed) == CertificateVerifyResult::OK); - //assert!(self_signed - // .verify( - // &self_signed - // .public_key() - // .expect("unable to get public key of cert") - // ) - // .expect("unable to verify public key of cert")); - - //assert!(matches!( - // x5chain - // .key_algorithm() - // .expect("unable to retrieve public key algorithm"), - // Algorithm::ES512 - //)); - } -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 706c066d..00000000 --- a/src/main.rs +++ /dev/null @@ -1,48 +0,0 @@ -use std::collections::BTreeMap; - -use anyhow::{Context, Error}; -use clap::Parser; -use clap_stdin::MaybeStdin; -use isomdl::presentation::{device::Document, Stringify}; - -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -struct Args { - #[command(subcommand)] - action: Action, -} - -#[derive(Debug, clap::Subcommand)] -enum Action { - /// Print the namespaces and element identifiers used in an mDL. - GetNamespaces { - /// Base64 encoded mDL in the format used in the issuance module of this crate. - mdl: MaybeStdin, - }, -} - -fn main() -> Result<(), Error> { - match Args::parse().action { - Action::GetNamespaces { mdl } => print_namespaces(mdl.to_string()), - } -} - -fn print_namespaces(mdl: String) -> Result<(), Error> { - let claims = Document::parse(mdl) - .context("could not parse mdl")? - .namespaces - .into_inner() - .into_iter() - .map(|(ns, inner)| (ns, inner.into_inner().into_keys().collect())) - .collect::>>(); - println!("{}", serde_json::to_string_pretty(&claims)?); - Ok(()) -} - -#[cfg(test)] -mod test { - #[test] - fn print_namespaces() { - super::print_namespaces(include_str!("../test/stringified-mdl.txt").to_string()).unwrap() - } -} diff --git a/src/presentation/authentication/mdoc.rs b/src/presentation/authentication/mdoc.rs new file mode 100644 index 00000000..01418f4b --- /dev/null +++ b/src/presentation/authentication/mdoc.rs @@ -0,0 +1,93 @@ +use crate::cbor; +use crate::cose; +use crate::definitions::device_response::Document; +use crate::definitions::issuer_signed; +use crate::definitions::x509::X5Chain; +use crate::definitions::DeviceAuth; +use crate::definitions::Mso; +use crate::definitions::{ + device_signed::DeviceAuthentication, helpers::Tag24, SessionTranscript180135, +}; +use crate::presentation::reader::Error; +use anyhow::Result; +use elliptic_curve::generic_array::GenericArray; +use issuer_signed::IssuerSigned; +use p256::ecdsa::Signature; +use p256::ecdsa::VerifyingKey; +use ssi_jwk::Params; +use ssi_jwk::JWK as SsiJwk; + +pub fn issuer_authentication(x5chain: X5Chain, issuer_signed: &IssuerSigned) -> Result<(), Error> { + let signer_key = x5chain + .end_entity_public_key() + .map_err(Error::IssuerPublicKey)?; + let verification_result: cose::sign1::VerificationResult = + issuer_signed + .issuer_auth + .verify::(&signer_key, None, None); + verification_result + .into_result() + .map_err(Error::IssuerAuthentication) +} + +pub fn device_authentication( + document: &Document, + session_transcript: SessionTranscript180135, +) -> Result<(), Error> { + let mso_bytes = document + .issuer_signed + .issuer_auth + .payload + .as_ref() + .ok_or(Error::DetachedIssuerAuth)?; + let mso: Tag24 = cbor::from_slice(mso_bytes).map_err(|_| Error::MSOParsing)?; + let device_key = mso.into_inner().device_key_info.device_key; + let jwk = SsiJwk::try_from(device_key)?; + match jwk.params { + Params::EC(p) => { + let x_coordinate = p.x_coordinate.clone(); + let y_coordinate = p.y_coordinate.clone(); + let (Some(x), Some(y)) = (x_coordinate, y_coordinate) else { + return Err(Error::MdocAuth( + "device key jwk is missing coordinates".to_string(), + )); + }; + let encoded_point = p256::EncodedPoint::from_affine_coordinates( + GenericArray::from_slice(x.0.as_slice()), + GenericArray::from_slice(y.0.as_slice()), + false, + ); + let verifying_key = VerifyingKey::from_encoded_point(&encoded_point)?; + let namespaces_bytes = &document.device_signed.namespaces; + let device_auth: &DeviceAuth = &document.device_signed.device_auth; + + match device_auth { + DeviceAuth::Signature { device_signature } => { + let detached_payload = Tag24::new(DeviceAuthentication::new( + session_transcript, + document.doc_type.clone(), + namespaces_bytes.clone(), + )) + .map_err(|_| Error::CborDecodingError)?; + let external_aad = None; + let cbor_payload = cbor::to_vec(&detached_payload)?; + let result = device_signature.verify::( + &verifying_key, + Some(&cbor_payload), + external_aad, + ); + if !result.is_success() { + Err(Error::ParsingError)? + } else { + Ok(()) + } + } + DeviceAuth::Mac { .. } => { + Err(Error::Unsupported) + // send not yet supported error + } + } + } + _ => Err(Error::MdocAuth("Unsupported device_key type".to_string())), + } +} diff --git a/src/presentation/authentication/mod.rs b/src/presentation/authentication/mod.rs new file mode 100644 index 00000000..b990c359 --- /dev/null +++ b/src/presentation/authentication/mod.rs @@ -0,0 +1,48 @@ +use std::collections::BTreeMap; + +use crate::presentation::device::RequestedItems; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Module containing functions to perform mdoc authentication. +pub mod mdoc; + +/// The outcome of the holder device authenticating the device request. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct RequestAuthenticationOutcome { + /// The requested items from the mDL namespace. + pub items_request: RequestedItems, + /// The common name from the certificate that signed this request, if available. + /// This value can be used to display to the user who the reader is, however + /// caution should be exercised if reader authentication was not successful. + pub common_name: Option, + /// Outcome of reader authentication. + pub reader_authentication: AuthenticationStatus, + /// Errors that occurred during request processing. + pub errors: Errors, +} + +/// The outcome of the reader device authenticating the device response. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct ResponseAuthenticationOutcome { + /// The values sent back from the holder device, serialized as JSON. + pub response: BTreeMap, + /// Outcome of issuer authentication. + pub issuer_authentication: AuthenticationStatus, + /// Outcome of device authentication. + pub device_authentication: AuthenticationStatus, + /// Errors that occurred during response processing. + pub errors: Errors, +} + +/// The outcome of authenticity checks. +#[derive(Debug, Serialize, Deserialize, Default)] +pub enum AuthenticationStatus { + #[default] + Unchecked, + Invalid, + Valid, +} + +/// Errors that occur during request/response processing. +pub type Errors = BTreeMap; diff --git a/src/presentation/device.rs b/src/presentation/device.rs index 4c5964cc..1ce2e158 100644 --- a/src/presentation/device.rs +++ b/src/presentation/device.rs @@ -16,14 +16,9 @@ //! //! You can view examples in `tests` directory in `simulated_device_and_reader.rs`, for a basic example and //! `simulated_device_and_reader_state.rs` which uses `State` pattern, `Arc` and `Mutex`. -use crate::cbor::CborError; -use crate::cose::mac0::PreparedCoseMac0; -use crate::cose::sign1::PreparedCoseSign1; -use crate::cose::MaybeTagged; -use crate::definitions::device_signed::DeviceAuthType; -use crate::definitions::IssuerSignedItem; use crate::{ cbor, + cose::{mac0::PreparedCoseMac0, sign1::PreparedCoseSign1, MaybeTagged}, definitions::{ device_engagement::{DeviceRetrievalMethod, Security, ServerRetrievalMethods}, device_request::{DeviceRequest, DocRequest, ItemsRequest}, @@ -31,24 +26,37 @@ use crate::{ Document as DeviceResponseDoc, DocumentError, DocumentErrorCode, DocumentErrors, Errors as NamespaceErrors, Status, }, - device_signed::{DeviceAuth, DeviceAuthentication, DeviceNamespacesBytes, DeviceSigned}, + device_signed::{ + DeviceAuth, DeviceAuthType, DeviceAuthentication, DeviceNamespacesBytes, DeviceSigned, + }, helpers::{tag24, NonEmptyMap, NonEmptyVec, Tag24}, issuer_signed::{IssuerSigned, IssuerSignedItemBytes}, session::{ self, derive_session_key, get_shared_secret, Handover, SessionData, SessionTranscript, }, - CoseKey, DeviceEngagement, DeviceResponse, Mso, SessionEstablishment, + x509::{ + self, trust_anchor::TrustAnchorRegistry, x5chain::X5CHAIN_COSE_HEADER_LABEL, X5Chain, + }, + CoseKey, DeviceEngagement, DeviceResponse, IssuerSignedItem, Mso, SessionEstablishment, }, issuance::Mdoc, }; +use coset::Label; use coset::{CoseMac0Builder, CoseSign1, CoseSign1Builder}; -use p256::FieldBytes; +use ecdsa::VerifyingKey; +use p256::{FieldBytes, NistP256}; use serde::{Deserialize, Serialize}; +use serde_json::json; use session::SessionTranscript180135; use std::collections::BTreeMap; use std::num::ParseIntError; use uuid::Uuid; +use super::{ + authentication::{AuthenticationStatus, RequestAuthenticationOutcome}, + reader::ReaderAuthentication, +}; + /// Initialisation state. /// /// You enter this state using [SessionManagerInit::initialise] method, providing @@ -70,7 +78,7 @@ pub struct SessionManagerInit { /// Engaged state. /// -/// Transition to this state is made with [SessionManagerInit::qr_engagement]. +/// Transition to this state is made with [SessionManagerInit::qr_engagement]. /// That creates the `QR code` that the reader will use to establish the session. #[derive(Clone, Serialize, Deserialize)] pub struct SessionManagerEngaged { @@ -103,6 +111,7 @@ pub struct SessionManager { sk_reader: [u8; 32], reader_message_counter: u32, state: State, + trusted_verifiers: TrustAnchorRegistry, device_auth_type: DeviceAuthType, } @@ -142,10 +151,20 @@ pub enum Error { /// `age_over` element identifier is malformed. #[error("age_over element identifier is malformed")] PrefixError, + #[error("error decoding reader authentication certificate")] + CertificateError, + #[error("error while validating reader authentication certificate")] + ValidationError, #[error("Could not serialize to cbor: {0}")] CborError(coset::CoseError), } +impl From for Error { + fn from(_value: x509_cert::der::Error) -> Self { + Error::CertificateError + } +} + /// The documents the device owns. pub type Documents = NonEmptyMap; type DocType = String; @@ -170,24 +189,30 @@ pub struct Document { /// After those are signed, they are kept in a list of [DeviceResponseDoc] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PreparedDeviceResponse { - prepared_documents: Vec, - signed_documents: Vec, - document_errors: Option, - status: Status, + pub prepared_documents: Vec, + pub signed_documents: Vec, + pub document_errors: Option, + pub status: Status, } #[derive(Debug, Clone, Serialize, Deserialize)] -struct PreparedDocument { - id: Uuid, - doc_type: String, - issuer_signed: IssuerSigned, - device_namespaces: DeviceNamespacesBytes, - prepared_cose: PreparedCose, - errors: Option, +pub struct PreparedDocument { + pub id: Uuid, + pub doc_type: String, + pub issuer_signed: IssuerSigned, + pub device_namespaces: DeviceNamespacesBytes, + pub prepared_cose: PreparedCose, + pub errors: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ReaderAuthOutcome { + pub common_name: Option, + pub errors: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] -enum PreparedCose { +pub enum PreparedCose { Sign1(PreparedCoseSign1), Mac0(PreparedCoseMac0), } @@ -278,7 +303,8 @@ impl SessionManagerEngaged { pub fn process_session_establishment( self, session_establishment: SessionEstablishment, - ) -> anyhow::Result<(SessionManager, RequestedItems)> { + trusted_verifiers: TrustAnchorRegistry, + ) -> anyhow::Result<(SessionManager, RequestAuthenticationOutcome)> { let e_reader_key = session_establishment.e_reader_key; let session_transcript = SessionTranscript180135(self.device_engagement, e_reader_key.clone(), self.handover); @@ -302,49 +328,72 @@ impl SessionManagerEngaged { sk_reader, reader_message_counter: 0, state: State::AwaitingRequest, + trusted_verifiers, device_auth_type: DeviceAuthType::Sign1, }; - let requested_data = sm.handle_decoded_request(SessionData { + let validated_request = sm.handle_decoded_request(SessionData { data: Some(session_establishment.data), status: None, - })?; + }); - Ok((sm, requested_data)) + Ok((sm, validated_request)) } } impl SessionManager { fn parse_request(&self, request: &[u8]) -> Result { - let request: ciborium::Value = cbor::from_slice(request).map_err(|_| { - // tracing::error!("unable to decode DeviceRequest bytes as cbor: {}", error); + let request: ciborium::Value = cbor::from_slice(request).map_err(|error| { + tracing::error!("unable to decode DeviceRequest bytes as cbor: {}", error); PreparedDeviceResponse::empty(Status::CborDecodingError) })?; - cbor::from_value(request).map_err(|_| { - // tracing::error!("unable to validate DeviceRequest cbor: {}", error); + cbor::from_value(request).map_err(|error| { + tracing::error!("unable to validate DeviceRequest cbor: {}", error); PreparedDeviceResponse::empty(Status::CborValidationError) }) } - fn validate_request( - &self, - request: DeviceRequest, - ) -> Result, PreparedDeviceResponse> { - if request.version != DeviceRequest::VERSION { - // tracing::error!( - // "unsupported DeviceRequest version: {} ({} is supported)", - // request.version, - // DeviceRequest::VERSION - // ); - return Err(PreparedDeviceResponse::empty(Status::GeneralError)); - } - Ok(request + fn validate_request(&self, request: DeviceRequest) -> RequestAuthenticationOutcome { + let items_request: Vec = request .doc_requests + .clone() .into_inner() .into_iter() .map(|DocRequest { items_request, .. }| items_request.into_inner()) - .collect()) + .collect(); + + let mut validated_request = RequestAuthenticationOutcome { + items_request, + common_name: None, + reader_authentication: AuthenticationStatus::Unchecked, + errors: BTreeMap::new(), + }; + + if request.version != DeviceRequest::VERSION { + tracing::error!( + "unsupported DeviceRequest version: {} ({} is supported)", + request.version, + DeviceRequest::VERSION + ); + validated_request.errors.insert( + "parsing_errors".to_string(), + json!(vec!["unsupported DeviceRequest version".to_string()]), + ); + } + if let Some(doc_request) = request.doc_requests.first() { + let outcome = self.reader_authentication(doc_request.clone()); + if outcome.errors.is_empty() { + validated_request.reader_authentication = AuthenticationStatus::Valid; + } else { + validated_request.reader_authentication = AuthenticationStatus::Invalid; + tracing::error!("Reader authentication errors: {:#?}", outcome.errors); + } + + validated_request.common_name = outcome.common_name; + } + + validated_request } /// When the device is ready to respond, it prepares the response specifying the permitted items. @@ -367,42 +416,65 @@ impl SessionManager { self.state = State::Signing(prepared_response); } - fn handle_decoded_request(&mut self, request: SessionData) -> anyhow::Result { - let data = request.data.ok_or_else(|| { - anyhow::anyhow!("no mdoc requests received, assume session can be terminated") - })?; - let decrypted_request = session::decrypt_reader_data( + fn handle_decoded_request(&mut self, request: SessionData) -> RequestAuthenticationOutcome { + let mut validated_request = RequestAuthenticationOutcome::default(); + let data = match request.data { + Some(d) => d, + None => { + validated_request.errors.insert( + "parsing_errors".to_string(), + json!(vec![ + "no mdoc requests received, assume session can be terminated".to_string() + ]), + ); + return validated_request; + } + }; + let decrypted_request = match session::decrypt_reader_data( &self.sk_reader.into(), data.as_ref(), &mut self.reader_message_counter, ) - .map_err(|e| anyhow::anyhow!("unable to decrypt request: {}", e))?; - let request = match self.parse_request(&decrypted_request) { - Ok(r) => r, + .map_err(|e| anyhow::anyhow!("unable to decrypt request: {}", e)) + { + Ok(decrypted) => decrypted, Err(e) => { - self.state = State::Signing(e); - return Ok(Default::default()); + validated_request + .errors + .insert("decryption_errors".to_string(), json!(vec![e.to_string()])); + return validated_request; } }; - let request = match self.validate_request(request) { + + let request = match self.parse_request(&decrypted_request) { Ok(r) => r, Err(e) => { self.state = State::Signing(e); - return Ok(Default::default()); + return RequestAuthenticationOutcome::default(); } }; - Ok(request) + + self.validate_request(request) } - /// Handle a new request from the reader. + /// Handle a request from the reader. /// - /// The request is expected to be a [CBOR](https://cbor.io) - /// encoded [SessionData] and encrypted. - /// It will parse and validate it. + /// The request is expected to be a CBOR encoded + /// and encrypted [SessionData]. /// - /// It returns the requested items by the reader. - pub fn handle_request(&mut self, request: &[u8]) -> anyhow::Result { - let session_data: SessionData = cbor::from_slice(request).map_err(CborError::from)?; + /// This method will return the [RequestAuthenticationOutcome] struct, which will + /// include the items requested by the reader/verifier. + pub fn handle_request(&mut self, request: &[u8]) -> RequestAuthenticationOutcome { + let mut validated_request = RequestAuthenticationOutcome::default(); + let session_data: SessionData = match cbor::from_slice(request) { + Ok(sd) => sd, + Err(e) => { + validated_request + .errors + .insert("parsing_errors".to_string(), json!(vec![e.to_string()])); + return validated_request; + } + }; self.handle_decoded_request(session_data) } @@ -506,6 +578,97 @@ impl SessionManager { None } } + + pub fn reader_authentication(&self, doc_request: DocRequest) -> ReaderAuthOutcome { + let mut outcome = ReaderAuthOutcome::default(); + + let Some(reader_auth) = doc_request.reader_auth else { + outcome + .errors + .push("Processing: request does not contain reader auth".into()); + return outcome; + }; + + let Some(x5chain_cbor) = reader_auth + .unprotected + .rest + .iter() + .find(|(label, _)| label == &Label::Int(X5CHAIN_COSE_HEADER_LABEL)) + .map(|(_, value)| value) + else { + outcome + .errors + .push("Processing: reader auth does not contain x5chain".into()); + return outcome; + }; + + let x5chain = match X5Chain::from_cbor(x5chain_cbor.clone()) { + Ok(x5c) => x5c, + Err(e) => { + outcome + .errors + .push(format!("Processing: x5chain cannot be decoded: {e}")); + return outcome; + } + }; + + outcome.common_name = Some(x5chain.end_entity_common_name().to_string()); + + let x5chain_validation_outcome = x509::validation::ValidationRuleset::MdlReaderOneStep + .validate(&x5chain, &self.trusted_verifiers); + + outcome.errors.extend(x5chain_validation_outcome.errors); + + // TODO: Support more than P-256. + let verifier: VerifyingKey = match x5chain.end_entity_public_key() { + Ok(verifier) => verifier, + Err(e) => { + outcome.errors.push(format!( + "Processing: reader public key cannot be decoded: {e}" + )); + return outcome; + } + }; + + let detached_payload = match Tag24::new(ReaderAuthentication( + "ReaderAuthentication".into(), + self.session_transcript.clone(), + doc_request.items_request, + )) { + Ok(tagged) => tagged, + Err(e) => { + outcome.errors.push(format!( + "Processing: failed to construct reader auth payload: {e}" + )); + return outcome; + } + }; + + let detached_payload = match cbor::to_vec(&detached_payload) { + Ok(bytes) => bytes, + Err(e) => { + outcome.errors.push(format!( + "Processing: failed to encode reader auth payload: {e}" + )); + return outcome; + } + }; + + let verification_outcome = reader_auth + .verify::, p256::ecdsa::Signature>( + &verifier, + Some(&detached_payload), + None, + ); + + if let Err(e) = verification_outcome.into_result() { + outcome.errors.push(format!( + "Verification: failed to verify reader auth signature: {e}" + )) + } + + outcome + } } impl PreparedDeviceResponse { @@ -850,7 +1013,7 @@ impl From for Document { } /// Filter permitted items to only permit the items that were requested. -fn filter_permitted(request: &RequestedItems, permitted: PermittedItems) -> PermittedItems { +pub fn filter_permitted(request: &RequestedItems, permitted: PermittedItems) -> PermittedItems { permitted .into_iter() .filter_map(|(doc_type, namespaces)| { @@ -1063,9 +1226,9 @@ mod test { let issuer_item1 = Tag24::new(issuer_signed_item1).unwrap(); let issuer_item2 = Tag24::new(issuer_signed_item2).unwrap(); let issuer_item3 = Tag24::new(issuer_signed_item3).unwrap(); - let mut issuer_items = NonEmptyMap::new(element_identifier1, issuer_item1.clone()); + let mut issuer_items = NonEmptyMap::new(element_identifier1, issuer_item1); issuer_items.insert(element_identifier2, issuer_item2.clone()); - issuer_items.insert(element_identifier3, issuer_item3.clone()); + issuer_items.insert(element_identifier3, issuer_item3); let result = nearest_age_attestation(requested_element_identifier, issuer_items) .expect("failed to process age attestation request"); diff --git a/src/presentation/mod.rs b/src/presentation/mod.rs index 1ae372e1..fca090e6 100644 --- a/src/presentation/mod.rs +++ b/src/presentation/mod.rs @@ -39,6 +39,7 @@ //! //! You can see the example in `simulated_device_and_reader.rs` from `examples` directory or a version that //! uses **State pattern**, `Arc` and `Mutex` `simulated_device_and_reader_state.rs`. +pub mod authentication; pub mod device; pub mod reader; diff --git a/src/presentation/reader.rs b/src/presentation/reader.rs index b1c95cc2..41bbdf0d 100644 --- a/src/presentation/reader.rs +++ b/src/presentation/reader.rs @@ -13,43 +13,70 @@ //! //! You can view examples in `tests` directory in `simulated_device_and_reader.rs`, for a basic example and //! `simulated_device_and_reader_state.rs` which uses `State` pattern, `Arc` and `Mutex`. -use crate::cbor; -use crate::cbor::CborError; -use crate::definitions::{ - device_engagement::DeviceRetrievalMethod, - device_request::{self, DeviceRequest, DocRequest, ItemsRequest}, - helpers::{NonEmptyVec, Tag24}, - session::{ - self, create_p256_ephemeral_keys, derive_session_key, get_shared_secret, Handover, - SessionEstablishment, - }, - DeviceEngagement, DeviceResponse, SessionData, SessionTranscript180135, -}; +use std::collections::BTreeMap; + +use anyhow::Context; use anyhow::{anyhow, Result}; +use coset::Label; use serde::{Deserialize, Serialize}; use serde_json::json; use serde_json::Value; -use std::collections::BTreeMap; use uuid::Uuid; +use super::authentication::{ + mdoc::{device_authentication, issuer_authentication}, + AuthenticationStatus, ResponseAuthenticationOutcome, +}; + +use crate::definitions::x509; +use crate::{ + cbor::{self, CborError}, + definitions::{ + device_engagement::DeviceRetrievalMethod, + device_key::cose_key::Error as CoseError, + device_request::{self, DeviceRequest, DocRequest, ItemsRequest}, + device_response::Document, + helpers::{non_empty_vec, NonEmptyVec, Tag24}, + session::{ + self, create_p256_ephemeral_keys, derive_session_key, get_shared_secret, Handover, + SessionEstablishment, + }, + x509::{trust_anchor::TrustAnchorRegistry, x5chain::X5CHAIN_COSE_HEADER_LABEL, X5Chain}, + DeviceEngagement, DeviceResponse, SessionData, SessionTranscript180135, + }, + presentation::reader::{device_request::ItemsRequestBytes, Error as ReaderError}, +}; + /// The main state of the reader. /// /// The reader's [SessionManager] state machine is responsible /// for handling the session with the device. /// /// The transition to this state is made by [SessionManager::establish_session]. -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, Clone)] pub struct SessionManager { session_transcript: SessionTranscript180135, sk_device: [u8; 32], device_message_counter: u32, sk_reader: [u8; 32], reader_message_counter: u32, + trust_anchor_registry: TrustAnchorRegistry, } +#[derive(Serialize, Deserialize)] +pub struct ReaderAuthentication( + pub String, + pub SessionTranscript180135, + pub ItemsRequestBytes, +); + /// Various errors that can occur during the interaction with the device. #[derive(Debug, thiserror::Error)] pub enum Error { + #[error("Received IssuerAuth had a detached payload.")] + DetachedIssuerAuth, + #[error("Could not parse MSO.")] + MSOParsing, /// The QR code had the wrong prefix or the contained data could not be decoded. #[error("the qr code had the wrong prefix or the contained data could not be decoded: {0}")] InvalidQrCode(anyhow::Error), @@ -77,14 +104,24 @@ pub enum Error { /// Not a valid JSON input. #[error("not a valid JSON input.")] JsonError, - /// Unexpected date type for data_element. - #[error("Unexpected date type for data_element.")] + /// Unexpected data type for data element. + #[error("Unexpected data type for data element.")] ParsingError, /// Request for data is invalid. #[error("Request for data is invalid.")] InvalidRequest, - #[error("Could not serialize to cbor: {0}")] - CborError(CborError), + #[error("Failed mdoc authentication: {0}")] + MdocAuth(String), + #[error("Currently unsupported format")] + Unsupported, + #[error("No x5chain found for issuer authentication")] + X5ChainMissing, + #[error("Failed to parse x5chain: {0}")] + X5ChainParsing(anyhow::Error), + #[error("issuer authentication failed: {0}")] + IssuerAuthentication(String), + #[error("Unable to parse issuer public key")] + IssuerPublicKey(anyhow::Error), } impl From for Error { @@ -99,6 +136,42 @@ impl From for Error { } } +impl From for Error { + fn from(value: x509_cert::der::Error) -> Self { + Error::MdocAuth(value.to_string()) + } +} + +impl From for Error { + fn from(value: p256::ecdsa::Error) -> Self { + Error::MdocAuth(value.to_string()) + } +} + +impl From for Error { + fn from(value: x509_cert::spki::Error) -> Self { + Error::MdocAuth(value.to_string()) + } +} + +impl From for Error { + fn from(value: CoseError) -> Self { + Error::MdocAuth(value.to_string()) + } +} + +impl From for Error { + fn from(value: non_empty_vec::Error) -> Self { + Error::MdocAuth(value.to_string()) + } +} + +impl From for Error { + fn from(value: asn1_rs::Error) -> Self { + Error::MdocAuth(value.to_string()) + } +} + impl SessionManager { /// Establish a session with the device. /// @@ -108,27 +181,31 @@ impl SessionManager { pub fn establish_session( qr_code: String, namespaces: device_request::Namespaces, + trust_anchor_registry: TrustAnchorRegistry, ) -> Result<(Self, Vec, [u8; 16])> { - let device_engagement_bytes = - Tag24::::from_qr_code_uri(&qr_code).map_err(Error::InvalidQrCode)?; + let device_engagement_bytes = Tag24::::from_qr_code_uri(&qr_code) + .context("failed to construct QR code")?; //generate own keys - let key_pair = create_p256_ephemeral_keys()?; + let key_pair = create_p256_ephemeral_keys().context("failed to generate ephemeral key")?; let e_reader_key_private = key_pair.0; - let e_reader_key_public = Tag24::new(key_pair.1)?; + let e_reader_key_public = + Tag24::new(key_pair.1).context("failed to encode public cose key")?; //decode device_engagement let device_engagement = device_engagement_bytes.as_ref(); let e_device_key = &device_engagement.security.1; // calculate ble Ident value - let ble_ident = super::calculate_ble_ident(e_device_key)?; + let ble_ident = + super::calculate_ble_ident(e_device_key).context("failed to calculate BLE Ident")?; // derive shared secret let shared_secret = get_shared_secret( e_device_key.clone().into_inner(), &e_reader_key_private.into(), - )?; + ) + .context("failed to derive shared session secret")?; let session_transcript = SessionTranscript180135( device_engagement_bytes, @@ -136,12 +213,16 @@ impl SessionManager { Handover::QR, ); - let session_transcript_bytes = Tag24::new(session_transcript.clone())?; + let session_transcript_bytes = Tag24::new(session_transcript.clone()) + .context("failed to encode session transcript")?; //derive session keys - let sk_reader = derive_session_key(&shared_secret, &session_transcript_bytes, true)?.into(); - let sk_device = - derive_session_key(&shared_secret, &session_transcript_bytes, false)?.into(); + let sk_reader = derive_session_key(&shared_secret, &session_transcript_bytes, true) + .context("failed to derive reader session key")? + .into(); + let sk_device = derive_session_key(&shared_secret, &session_transcript_bytes, false) + .context("failed to derive device session key")? + .into(); let mut session_manager = Self { session_transcript, @@ -149,14 +230,18 @@ impl SessionManager { device_message_counter: 0, sk_reader, reader_message_counter: 0, + trust_anchor_registry, }; - let request = session_manager.build_request(namespaces)?; + let request = session_manager + .build_request(namespaces) + .context("failed to build device request")?; let session = SessionEstablishment { data: request.into(), e_reader_key: e_reader_key_public, }; - let session_request = cbor::to_vec(&session)?; + let session_request = + cbor::to_vec(&session).context("failed to encode session establishment")?; Ok((session_manager, session_request, ble_ident)) } @@ -201,6 +286,7 @@ impl SessionManager { namespaces, request_info: None, }; + let doc_request = DocRequest { reader_auth: None, items_request: Tag24::new(items_request)?, @@ -218,16 +304,7 @@ impl SessionManager { .map_err(|e| anyhow!("unable to encrypt request: {}", e)) } - /// Handles a response from the device. - /// - /// The response is expected to be a [CBOR](https://cbor.io) - /// encoded [SessionData] and encrypted. - /// - /// Will return the elements and values grouped by namespace. - pub fn handle_response( - &mut self, - response: &[u8], - ) -> Result>, Error> { + fn decrypt_response(&mut self, response: &[u8]) -> Result { let session_data: SessionData = cbor::from_slice(response)?; let encrypted_response = match session_data.data { None => return Err(Error::HolderError), @@ -239,64 +316,105 @@ impl SessionManager { &mut self.device_message_counter, ) .map_err(|_e| Error::DecryptionError)?; - let response: DeviceResponse = cbor::from_slice(&decrypted_response)?; - let mut core_namespace = BTreeMap::::new(); - let mut aamva_namespace = BTreeMap::::new(); - let mut parsed_response = BTreeMap::>::new(); - - let mut namespaces = response - .documents - .ok_or(Error::DeviceTransmissionError)? - .into_inner() - .into_iter() - .find(|doc| doc.doc_type == "org.iso.18013.5.1.mDL") - .ok_or(Error::DocumentTypeError)? - .issuer_signed - .namespaces - .ok_or(Error::NoMdlDataTransmission)? - .into_inner(); - - // Check if at least one of the two namespaces exists - if !namespaces.contains_key("org.iso.18013.5.1") - && !namespaces.contains_key("org.iso.18013.5.1.aamva") - { - return Err(Error::IncorrectNamespace); - } + let device_response: DeviceResponse = cbor::from_slice(&decrypted_response)?; + Ok(device_response) + } - if let Some(core_response) = namespaces.remove("org.iso.18013.5.1") { - core_response - .into_inner() - .into_iter() - .map(|item| item.into_inner()) - .for_each(|item| { - let value = parse_response(item.element_value.clone()); - if let Ok(val) = value { - core_namespace.insert(item.element_identifier, val); - } - }); - - parsed_response.insert("org.iso.18013.5.1".to_string(), core_namespace); + pub fn handle_response(&mut self, response: &[u8]) -> ResponseAuthenticationOutcome { + let mut validated_response = ResponseAuthenticationOutcome::default(); + + let device_response = match self.decrypt_response(response) { + Ok(device_response) => device_response, + Err(e) => { + validated_response.errors.insert( + "decryption_errors".to_string(), + json!(vec![format!("{e:?}")]), + ); + return validated_response; + } + }; + + match parse(&device_response) { + Ok((document, x5chain, namespaces)) => { + self.validate_response(x5chain, document.clone(), namespaces) + } + Err(e) => { + validated_response + .errors + .insert("parsing_errors".to_string(), json!(vec![format!("{e:?}")])); + validated_response + } } + } - if let Some(aamva_response) = namespaces.remove("org.iso.18013.5.1.aamva") { - aamva_response - .into_inner() - .into_iter() - .map(|item| item.into_inner()) - .for_each(|item| { - let value = parse_response(item.element_value.clone()); - if let Ok(val) = value { - aamva_namespace.insert(item.element_identifier, val); - } - }); - - parsed_response.insert("org.iso.18013.5.1.aamva".to_string(), aamva_namespace); + fn validate_response( + &mut self, + x5chain: X5Chain, + document: Document, + namespaces: BTreeMap, + ) -> ResponseAuthenticationOutcome { + let mut validated_response = ResponseAuthenticationOutcome { + response: namespaces, + ..Default::default() + }; + + match device_authentication(&document, self.session_transcript.clone()) { + Ok(_) => { + validated_response.device_authentication = AuthenticationStatus::Valid; + } + Err(e) => { + validated_response.device_authentication = AuthenticationStatus::Invalid; + validated_response.errors.insert( + "device_authentication_errors".to_string(), + json!(vec![format!("{e:?}")]), + ); + } } - Ok(parsed_response) + let validation_errors = x509::validation::ValidationRuleset::Mdl + .validate(&x5chain, &self.trust_anchor_registry) + .errors; + if validation_errors.is_empty() { + match issuer_authentication(x5chain, &document.issuer_signed) { + Ok(_) => { + validated_response.issuer_authentication = AuthenticationStatus::Valid; + } + Err(e) => { + validated_response.issuer_authentication = AuthenticationStatus::Invalid; + validated_response.errors.insert( + "issuer_authentication_errors".to_string(), + serde_json::json!(vec![format!("{e:?}")]), + ); + } + } + } else { + validated_response + .errors + .insert("certificate_errors".to_string(), json!(validation_errors)); + validated_response.issuer_authentication = AuthenticationStatus::Invalid + }; + + validated_response } } +fn parse( + device_response: &DeviceResponse, +) -> Result<(&Document, X5Chain, BTreeMap), Error> { + let document = get_document(device_response)?; + let header = document.issuer_signed.issuer_auth.unprotected.clone(); + let x5chain = header + .rest + .iter() + .find(|(label, _)| label == &Label::Int(X5CHAIN_COSE_HEADER_LABEL)) + .map(|(_, value)| value.to_owned()) + .map(X5Chain::from_cbor) + .ok_or(Error::X5ChainMissing)? + .map_err(Error::X5ChainParsing)?; + let parsed_response = parse_namespaces(device_response)?; + Ok((document, x5chain, parsed_response)) +} + fn parse_response(value: ciborium::Value) -> Result { match value { ciborium::Value::Text(s) => Ok(Value::String(s)), @@ -333,7 +451,19 @@ fn parse_response(value: ciborium::Value) -> Result { } } +fn get_document(device_response: &DeviceResponse) -> Result<&Document, Error> { + device_response + .documents + .as_ref() + .ok_or(ReaderError::DeviceTransmissionError)? + .iter() + .find(|doc| doc.doc_type == "org.iso.18013.5.1.mDL") + .ok_or(ReaderError::DocumentTypeError) +} + fn _validate_request(namespaces: device_request::Namespaces) -> Result { + // TODO: Check country name of certificate matches mdl + // Check if request follows ISO18013-5 restrictions // A valid mdoc request can contain a maximum of 2 age_over_NN fields let age_over_nn_requested: Vec<(String, bool)> = namespaces @@ -353,8 +483,67 @@ fn _validate_request(namespaces: device_request::Namespaces) -> Result Result, Error> { + let mut core_namespace = BTreeMap::::new(); + let mut aamva_namespace = BTreeMap::::new(); + let mut parsed_response = BTreeMap::::new(); + let mut namespaces = device_response + .documents + .as_ref() + .ok_or(Error::DeviceTransmissionError)? + .iter() + .find(|doc| doc.doc_type == "org.iso.18013.5.1.mDL") + .ok_or(Error::DocumentTypeError)? + .issuer_signed + .namespaces + .as_ref() + .ok_or(Error::NoMdlDataTransmission)? + .clone() + .into_inner(); + + namespaces + .remove("org.iso.18013.5.1") + .ok_or(Error::IncorrectNamespace)? + .into_inner() + .into_iter() + .map(|item| item.into_inner()) + .for_each(|item| { + let value = parse_response(item.element_value.clone()); + if let Ok(val) = value { + core_namespace.insert(item.element_identifier, val); + } + }); + + parsed_response.insert( + "org.iso.18013.5.1".to_string(), + serde_json::to_value(core_namespace)?, + ); + + if let Some(aamva_response) = namespaces.remove("org.iso.18013.5.1.aamva") { + aamva_response + .into_inner() + .into_iter() + .map(|item| item.into_inner()) + .for_each(|item| { + let value = parse_response(item.element_value.clone()); + if let Ok(val) = value { + aamva_namespace.insert(item.element_identifier, val); + } + }); + + parsed_response.insert( + "org.iso.18013.5.1.aamva".to_string(), + serde_json::to_value(aamva_namespace)?, + ); + } + Ok(parsed_response) +} + #[cfg(test)] -mod test { +pub mod test { use super::*; #[test] diff --git a/test/presentation/isomdl_iaca_intermediate.pem b/test/presentation/isomdl_iaca_intermediate.pem new file mode 100644 index 00000000..2614d056 --- /dev/null +++ b/test/presentation/isomdl_iaca_intermediate.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICszCCAlmgAwIBAgIUDtWU4pfEqD+snpSJWqZdVCOmm1YwCgYIKoZIzj0EAwIw +YDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk5ZMRkwFwYDVQQKDBBJU09tREwgVGVz +dCBSb290MSkwJwYDVQQDDCBJU08xODAxMy01IFRlc3QgQ2VydGlmaWNhdGUgUm9v +dDAeFw0yMzEwMjcxMTU1MzVaFw0yNDEwMjYxMTU1MzVaMHAxCzAJBgNVBAYTAlVT +MQswCQYDVQQIDAJOWTEhMB8GA1UECgwYVGVzdCBJc3N1ZXIgSW50ZXJtZWRpYXRl +MTEwLwYDVQQDDChJU08xODAxMy01IFRlc3QgQ2VydGlmaWNhdGUgSW50ZXJtZWRp +YXRlMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbPSWUqsMjJORnuuuY+bEnC0C +8snYSVoLjal5GDHI9EnFsQ61zEyzjXG5sTP9C8ZKtKDDwlC33oDuCyivTz4seKOB +4DCB3TAdBgNVHQ4EFgQUc6xThx1W03ausUIu7SVVJ9HzyK0wHwYDVR0jBBgwFoAU +NUaDsc9OHULyjxe+lYQX/ozL9xAwLwYJYIZIAYb4QgENBCIWIFNwcnVjZUlEIFRl +c3QgU2lnbmVyIENlcnRpZmljYXRlMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUBAf8E +CzAJBgcogYxdBQECMBIGA1UdEwEB/wQIMAYBAf8CAQAwLwYDVR0fBCgwJjAkoCKg +IIYeaHR0cHM6Ly9leGFtcGxlLmNvbS9JU09tREwuY3JsMAoGCCqGSM49BAMCA0gA +MEUCIQDxJWFzncbLUZHLC6I/oI7Pe9pxkzwbtnhM9Y0d0aUzxQIgFw4nCHkKcQ60 +R4Vyx3ylTXgs1RZfC/J/5IEGfA/x4pk= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/test/presentation/isomdl_iaca_leaf_signer.pem b/test/presentation/isomdl_iaca_leaf_signer.pem new file mode 100644 index 00000000..e804e69c --- /dev/null +++ b/test/presentation/isomdl_iaca_leaf_signer.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICwDCCAmagAwIBAgIUc/HqsvizevQlo9eQDVjNsnWgyAkwCgYIKoZIzj0EAwIw +cDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk5ZMSEwHwYDVQQKDBhUZXN0IElzc3Vl +ciBJbnRlcm1lZGlhdGUxMTAvBgNVBAMMKElTTzE4MDEzLTUgVGVzdCBDZXJ0aWZp +Y2F0ZSBJbnRlcm1lZGlhdGUwHhcNMjMxMDI3MTE1NzM1WhcNMjQxMDI2MTE1NzM1 +WjBrMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTlkxIjAgBgNVBAoMGUlTT21ETCBU +ZXN0IElzc3VlciBTaWduZXIxKzApBgNVBAMMIklTTzE4MDEzLTUgVGVzdCBDZXJ0 +aWZpY2F0ZSBTaWduZXIwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQgaK6KmQ1m +WKt+Vo6ixfHxsmX9YlGAuUPkOvQ/uHrxgsZLC6FheRwtU3v+5GGkHD70FJNmz7DJ +UiR6G8TWMYZGo4HiMIHfMB0GA1UdDgQWBBQEpN0hSF6BFZJCDvZwASaa6ewoXzAf +BgNVHSMEGDAWgBRzrFOHHVbTdq6xQi7tJVUn0fPIrTAxBglghkgBhvhCAQ0EJBYi +SVNPMTgwMTMtNSBUZXN0IFNpZ25lciBDZXJ0aWZpY2F0ZTAOBgNVHQ8BAf8EBAMC +B4AwFQYDVR0lAQH/BAswCQYHKIGMXQUBAjASBgNVHRMBAf8ECDAGAQH/AgEAMC8G +A1UdHwQoMCYwJKAioCCGHmh0dHBzOi8vZXhhbXBsZS5jb20vSVNPbURMLmNybDAK +BggqhkjOPQQDAgNIADBFAiEA7eSLtsp2y/p9diZGAVzgc0evUbIi+HYAd70U5PUQ +2hoCIAa9cHJ5wB0Rz4VZqTAf1jNlUaWNo1p+tmYRbtAprJ0Q +-----END CERTIFICATE----- \ No newline at end of file diff --git a/test/presentation/isomdl_iaca_root_cert.pem b/test/presentation/isomdl_iaca_root_cert.pem new file mode 100644 index 00000000..497091cd --- /dev/null +++ b/test/presentation/isomdl_iaca_root_cert.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICWTCCAf+gAwIBAgIUOx5RMyn2LSq23/Ua8xK9VqYDWXowCgYIKoZIzj0EAwIw +YDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk5ZMRkwFwYDVQQKDBBJU09tREwgVGVz +dCBSb290MSkwJwYDVQQDDCBJU08xODAxMy01IFRlc3QgQ2VydGlmaWNhdGUgUm9v +dDAeFw0yMzEwMjcxMTMxMTFaFw0yNDEwMjYxMTMxMTFaMGAxCzAJBgNVBAYTAlVT +MQswCQYDVQQIDAJOWTEZMBcGA1UECgwQSVNPbURMIFRlc3QgUm9vdDEpMCcGA1UE +AwwgSVNPMTgwMTMtNSBUZXN0IENlcnRpZmljYXRlIFJvb3QwWTATBgcqhkjOPQIB +BggqhkjOPQMBBwNCAATsKrbaIJnKjZpyVuNq75Nv9oOMkq2ufxKclcOof1ab6ivr +mnKqA9BcIpA8sFM9DUz3KIRo7iRprmcWVBYuMBeTo4GWMIGTMB0GA1UdDgQWBBQ1 +RoOxz04dQvKPF76VhBf+jMv3EDASBgNVHRMBAf8ECDAGAQH/AgEAMC8GA1UdHwQo +MCYwJKAioCCGHmh0dHBzOi8vZXhhbXBsZS5jb20vSVNPbURMLmNybDAOBgNVHQ8B +Af8EBAMCAQYwHQYDVR0SBBYwFIESZXhhbXBsZUBpc29tZGwuY29tMAoGCCqGSM49 +BAMCA0gAMEUCIQCzFJlt0Wl03lBgOOuO184budY3dyqVb/xxdTKACl8hkAIgCCrb +qgXqMD7z4K9XyaGxUq7QhdMBKiE3rNS193xqGs8= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/test/presentation/isomdl_iaca_signer.pem b/test/presentation/isomdl_iaca_signer.pem new file mode 100644 index 00000000..38a4335d --- /dev/null +++ b/test/presentation/isomdl_iaca_signer.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICujCCAmGgAwIBAgIUDtWU4pfEqD+snpSJWqZdVCOmm1cwCgYIKoZIzj0EAwIw +YDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk5ZMRkwFwYDVQQKDBBJU09tREwgVGVz +dCBSb290MSkwJwYDVQQDDCBJU08xODAxMy01IFRlc3QgQ2VydGlmaWNhdGUgUm9v +dDAeFw0yMzExMTQxMDAxMDVaFw0yNDExMTMxMDAxMDVaMGsxCzAJBgNVBAYTAlVT +MQswCQYDVQQIDAJOWTEiMCAGA1UECgwZSVNPbURMIFRlc3QgSXNzdWVyIFNpZ25l +cjErMCkGA1UEAwwiSVNPMTgwMTMtNSBUZXN0IENlcnRpZmljYXRlIFNpZ25lcjBZ +MBMGByqGSM49AgEGCCqGSM49AwEHA0IABCBoroqZDWZYq35WjqLF8fGyZf1iUYC5 +Q+Q69D+4evGCxksLoWF5HC1Te/7kYaQcPvQUk2bPsMlSJHobxNYxhkajge0wgeow +HQYDVR0OBBYEFASk3SFIXoEVkkIO9nABJprp7ChfMB8GA1UdIwQYMBaAFDVGg7HP +Th1C8o8XvpWEF/6My/cQMDEGCWCGSAGG+EIBDQQkFiJJU08xODAxMy01IFRlc3Qg +U2lnbmVyIENlcnRpZmljYXRlMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUBAf8ECzAJ +BgcogYxdBQECMB0GA1UdEgQWMBSBEmV4YW1wbGVAaXNvbWRsLmNvbTAvBgNVHR8E +KDAmMCSgIqAghh5odHRwczovL2V4YW1wbGUuY29tL0lTT21ETC5jcmwwCgYIKoZI +zj0EAwIDRwAwRAIgZXkJDQQUaMXNUFKpZ9o9VQzOJ6xxVDhb6XHDlurucIECIF/U +P1I94DTlJ1/SJYdqdbc3QUG1LsjPld1IpZPYiowo +-----END CERTIFICATE----- diff --git a/test/presentation/isomdl_incorrect_iaca_signer.pem b/test/presentation/isomdl_incorrect_iaca_signer.pem new file mode 100644 index 00000000..0abbe0a1 --- /dev/null +++ b/test/presentation/isomdl_incorrect_iaca_signer.pem @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE----- +MIICsTCCAlagAwIBAgIUDtWU4pfEqD+snpSJWqZdVCOmm1UwCgYIKoZIzj0EAwIw +YDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk5ZMRkwFwYDVQQKDBBJU09tREwgVGVz +dCBSb290MSkwJwYDVQQDDCBJU08xODAxMy01IFRlc3QgQ2VydGlmaWNhdGUgUm9v +dDAeFw0yMzEwMjcxMTUzMjRaFw0yNDEwMjYxMTUzMjRaMGsxCzAJBgNVBAYTAlVT +MQswCQYDVQQIDAJOWTEiMCAGA1UECgwZSVNPbURMIFRlc3QgSXNzdWVyIFNpZ25l +cjErMCkGA1UEAwwiSVNPMTgwMTMtNSBUZXN0IENlcnRpZmljYXRlIFNpZ25lcjBZ +MBMGByqGSM49AgEGCCqGSM49AwEHA0IABCBoroqZDWZYq35WjqLF8fGyZf1iUYC5 +Q+Q69D+4evGCxksLoWF5HC1Te/7kYaQcPvQUk2bPsMlSJHobxNYxhkajgeIwgd8w +HQYDVR0OBBYEFASk3SFIXoEVkkIO9nABJprp7ChfMB8GA1UdIwQYMBaAFDVGg7HP +Th1C8o8XvpWEF/6My/cQMDEGCWCGSAGG+EIBDQQkFiJJU08xODAxMy01IFRlc3Qg +U2lnbmVyIENlcnRpZmljYXRlMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUBAf8ECzAJ +BgcogYxdBQECMBIGA1UdEwEB/wQIMAYBAf8CAQAwLwYDVR0fBCgwJjAkoCKgIIYe +aHR0cHM6Ly9leGFtcGxlLmNvbS9JU09tREwuY3JsMAoGCCqGSM49BAMCA0kAMEYC +IQD7jaJK++LzevufF2oLiE1d9GCoij7ibBqDaGSI1SkQSQIhAMBs/ErQQkFADiep +5lN3kt8xhZrrFxxqHTTisJq5v+qW +-----END CERTIFICATE----- \ No newline at end of file diff --git a/test/presentation/reader_auth.pem b/test/presentation/reader_auth.pem new file mode 100644 index 00000000..3d7ed57c --- /dev/null +++ b/test/presentation/reader_auth.pem @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIICfzCCAiWgAwIBAgIUa1sPN12Jdv6KwSjG3DJeK6DCm0cwCgYIKoZIzj0EAwIw +bjELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk5ZMSAwHgYDVQQKDBdJU09tREwgVGVz +dCBSZWFkZXIgUm9vdDEwMC4GA1UEAwwnSVNPMTgwMTMtNSBUZXN0IENlcnRpZmlj +YXRlIFJlYWRlciBSb290MB4XDTIzMTEyMDEzMjYwMloXDTI0MDUyNDEzMjYwMlow +VDELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk5ZMRcwFQYDVQQKDA5TcHJ1Y2UgU3lz +dGVtczEfMB0GA1UEAwwWSVNPMTgwMTMtNSBUZXN0IFJlYWRlcjBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABCm4PuNX0645fokw5XwZ5MpMtY0G4z+b1PvE/5Zx8As5 +4c9VAeVHb1Mlw59GPNBGU2xzccPZF8qsInT1JBd4cqOjgbowgbcwHQYDVR0OBBYE +FCyPAvWShVVL9dkiTlZQuL7kOtSjMB8GA1UdIwQYMBaAFFhiV3bwFCly/JtNCDvK +NUQxvDVmMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUBAf8ECzAJBgcogYxdBQEGMB0G +A1UdEgQWMBSBEmV4YW1wbGVAaXNvbWRsLmNvbTAvBgNVHR8EKDAmMCSgIqAghh5o +dHRwczovL2V4YW1wbGUuY29tL0lTT21ETC5jcmwwCgYIKoZIzj0EAwIDSAAwRQIg +XB7Y464ffTiQr32lfm/30S6HuvIsghovj1NFWcBGuCECIQCxGGShlVzrjTDsfahx +3LPTEI8prVIfLclczAvOOMq30A== +-----END CERTIFICATE----- diff --git a/test/presentation/reader_key.pem b/test/presentation/reader_key.pem new file mode 100644 index 00000000..c4b18090 --- /dev/null +++ b/test/presentation/reader_key.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIFkWRnOYuhN/3iJLTcBXZdvwbnWWAppQSZvc5OlzROK6oAoGCCqGSM49 +AwEHoUQDQgAEKbg+41fTrjl+iTDlfBnkyky1jQbjP5vU+8T/lnHwCznhz1UB5Udv +UyXDn0Y80EZTbHNxw9kXyqwidPUkF3hyow== +-----END EC PRIVATE KEY----- diff --git a/tests/common.rs b/tests/common.rs index 6cd060af..d9f49c95 100644 --- a/tests/common.rs +++ b/tests/common.rs @@ -4,9 +4,12 @@ use isomdl::cbor; use isomdl::definitions::device_engagement::{CentralClientMode, DeviceRetrievalMethods}; use isomdl::definitions::device_request::{DataElements, DocType, Namespaces}; use isomdl::definitions::helpers::NonEmptyMap; +use isomdl::definitions::x509::trust_anchor::TrustAnchorRegistry; use isomdl::definitions::{self, BleOptions, DeviceRetrievalMethod}; use isomdl::presentation::device::{Document, Documents, RequestedItems, SessionManagerEngaged}; -use isomdl::presentation::{device, reader, Stringify}; +use isomdl::presentation::{ + authentication::RequestAuthenticationOutcome, device, reader, Stringify, +}; use signature::Signer; use uuid::Uuid; @@ -51,8 +54,11 @@ impl Device { NAMESPACE.into(), DataElements::new(AGE_OVER_21_ELEMENT.to_string(), false), ); + + let trust_anchors = TrustAnchorRegistry::default(); + let (reader_sm, session_request, _ble_ident) = - reader::SessionManager::establish_session(qr, requested_elements) + reader::SessionManager::establish_session(qr, requested_elements, trust_anchors) .context("failed to establish reader session")?; Ok((reader_sm, session_request)) } @@ -61,24 +67,25 @@ impl Device { pub fn handle_request( state: SessionManagerEngaged, request: Vec, - ) -> Result<(device::SessionManager, RequestedItems)> { - let (session_manager, items_requests) = { + trusted_verifiers: TrustAnchorRegistry, + ) -> Result<(device::SessionManager, RequestAuthenticationOutcome)> { + let (session_manager, validated_request) = { let session_establishment: definitions::SessionEstablishment = cbor::from_slice(&request).context("could not deserialize request")?; state - .process_session_establishment(session_establishment) + .process_session_establishment(session_establishment, trusted_verifiers) .context("could not process process session establishment")? }; if session_manager.get_next_signature_payload().is_some() { anyhow::bail!("there were errors processing request"); } - Ok((session_manager, items_requests)) + Ok((session_manager, validated_request)) } /// Prepare response with required elements. pub fn create_response( mut session_manager: device::SessionManager, - requested_items: RequestedItems, + requested_items: &RequestedItems, key: &p256::ecdsa::SigningKey, ) -> Result> { let permitted_items = [( @@ -89,7 +96,7 @@ impl Device { )] .into_iter() .collect(); - session_manager.prepare_response(&requested_items, permitted_items); + session_manager.prepare_response(requested_items, permitted_items); let (_, sign_payload) = session_manager.get_next_signature_payload().unwrap(); let signature: p256::ecdsa::Signature = key.sign(sign_payload); session_manager @@ -113,8 +120,8 @@ impl Reader { reader_sm: &mut reader::SessionManager, response: Vec, ) -> Result<()> { - let res = reader_sm.handle_response(&response)?; - println!("{:?}", res); + let validated = reader_sm.handle_response(&response); + println!("Validated Response: {validated:?}"); Ok(()) } } diff --git a/tests/data/sec1.pem b/tests/data/sec1.pem index 5bbe5ee9..32d96261 100644 --- a/tests/data/sec1.pem +++ b/tests/data/sec1.pem @@ -2,4 +2,4 @@ MGsCAQEEIBAKimXWUID1bY5RAX89iLRxvKFyDjXpzsUXj7ajkONsoUQDQgAEwON4 CNiG/PqiiuGBDnzQkYjToch2gi4ALynYR0vsusKRt6CdsbnV2oMyim3H71HWuMdI /M0df6A+epqZvORmzg== ------END EC PRIVATE KEY----- +-----END EC PRIVATE KEY----- \ No newline at end of file diff --git a/tests/simulated_device_and_reader.rs b/tests/simulated_device_and_reader.rs index b7c0ba0a..b6b99fa9 100644 --- a/tests/simulated_device_and_reader.rs +++ b/tests/simulated_device_and_reader.rs @@ -1,28 +1,33 @@ mod common; -use anyhow::Result; - use crate::common::{Device, Reader}; #[test] -pub fn simulated_device_and_reader_interaction() -> Result<()> { +pub fn simulated_device_and_reader_interaction() { let key: p256::ecdsa::SigningKey = - p256::SecretKey::from_sec1_pem(include_str!("data/sec1.pem"))?.into(); + p256::SecretKey::from_sec1_pem(include_str!("data/sec1.pem")) + .unwrap() + .into(); // Device initialization and engagement - let (engaged_state, qr_code_uri) = Device::initialise_session()?; + let (engaged_state, qr_code_uri) = Device::initialise_session().unwrap(); // Reader processing QR and requesting the necessary fields - let (mut reader_session_manager, request) = Device::establish_reader_session(qr_code_uri)?; + let (mut reader_session_manager, request) = + Device::establish_reader_session(qr_code_uri).unwrap(); // Device accepting request - let (device_session_manager, requested_items) = Device::handle_request(engaged_state, request)?; + let (device_session_manager, validated_request) = + Device::handle_request(engaged_state, request, Default::default()).unwrap(); // Prepare response with required elements - let response = Device::create_response(device_session_manager, requested_items, &key)?; + let response = Device::create_response( + device_session_manager, + &validated_request.items_request, + &key, + ) + .unwrap(); // Reader Processing mDL data - Reader::reader_handle_device_response(&mut reader_session_manager, response)?; - - Ok(()) + Reader::reader_handle_device_response(&mut reader_session_manager, response).unwrap(); } diff --git a/tests/simulated_device_and_reader_state.rs b/tests/simulated_device_and_reader_state.rs index 7b663460..56c44817 100644 --- a/tests/simulated_device_and_reader_state.rs +++ b/tests/simulated_device_and_reader_state.rs @@ -4,6 +4,7 @@ use anyhow::{Context, Result}; use isomdl::cbor; use isomdl::definitions::device_engagement::{CentralClientMode, DeviceRetrievalMethods}; use isomdl::definitions::device_request::{DataElements, Namespaces}; +use isomdl::definitions::x509::trust_anchor::TrustAnchorRegistry; use isomdl::definitions::{self, BleOptions, DeviceRetrievalMethod}; use isomdl::presentation::device::{Documents, RequestedItems}; use isomdl::presentation::{device, reader}; @@ -25,7 +26,7 @@ struct RequestData { struct SessionManager { inner: Mutex, - items_requests: RequestedItems, + items_request: RequestedItems, key: Arc, } @@ -96,8 +97,10 @@ fn establish_reader_session(qr: String) -> Result<(reader::SessionManager, Vec, key: Arc, ) -> Result> { - let (session_manager, items_requests) = { + let (session_manager, validated_response) = { let session_establishment: definitions::SessionEstablishment = cbor::from_slice(&request).context("could not deserialize request")?; state .0 .clone() - .process_session_establishment(session_establishment) + .process_session_establishment(session_establishment, Default::default()) .context("could not process process session establishment")? }; let session_manager = Arc::new(SessionManager { inner: Mutex::new(session_manager), - items_requests: items_requests.clone(), + items_request: validated_response.items_request.clone(), key, }); // Propagate any errors back to the reader if let Ok(Some(response)) = get_errors(session_manager.clone()) { - let res = reader_session_manager.handle_response(&response)?; - println!("Reader: {res:?}"); + let validated_response = reader_session_manager.handle_response(&response); + println!("Reader: {validated_response:?}"); return Ok(None); }; @@ -147,7 +150,7 @@ fn create_response(session_manager: Arc) -> Result> { .inner .lock() .unwrap() - .prepare_response(&session_manager.items_requests, permitted_items); + .prepare_response(&session_manager.items_request, permitted_items); sign_pending_and_retrieve_response(session_manager.clone(), Some(1))? .ok_or_else(|| anyhow::anyhow!("cannot prepare response")) } @@ -185,7 +188,7 @@ fn reader_handle_device_response( reader_sm: &mut reader::SessionManager, response: Vec, ) -> Result<()> { - let res = reader_sm.handle_response(&response)?; - println!("{:?}", res); + let validated_response = reader_sm.handle_response(&response); + println!("Validated Response: {validated_response:?}"); Ok(()) }