diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index 2bdab2d..3f9b67b 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -17,6 +17,9 @@ //! ``` use roxmltree::{Document, Node}; +use x509_parser::extensions::ParsedExtension; +use x509_parser::prelude::FromDer; +use x509_parser::public_key::PublicKey; use super::digest::DigestAlgorithm; use super::transforms::{self, Transform}; @@ -169,6 +172,8 @@ pub enum KeyValueInfo { #[non_exhaustive] pub struct X509DataInfo { /// DER-encoded certificates from ``. + /// + /// This vector has a 1:1 index correspondence with `parsed_certificates`. pub certificates: Vec>, /// Text values from ``. pub subject_names: Vec, @@ -180,6 +185,49 @@ pub struct X509DataInfo { pub crls: Vec>, /// `(Algorithm URI, digest bytes)` tuples from `dsig11:X509Digest`. pub digests: Vec<(String, Vec)>, + /// Parsed metadata for each `` entry. + /// + /// This vector has a 1:1 index correspondence with `certificates`. + pub parsed_certificates: Vec, +} + +/// Parsed X.509 certificate details extracted from DER. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct ParsedX509Certificate { + /// Subject distinguished name. + pub subject_dn: String, + /// Issuer distinguished name. + pub issuer_dn: String, + /// Subject Key Identifier extension bytes (if present). + pub subject_key_identifier: Option>, + /// Parsed certificate public key material. + pub public_key: X509PublicKeyInfo, +} + +/// Public key material extracted from certificate SubjectPublicKeyInfo. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum X509PublicKeyInfo { + /// RSA public key (`modulus`, `exponent`). + Rsa { + /// Unsigned big-endian RSA modulus (`n`), normalized without leading zeroes. + modulus: Vec, + /// Unsigned big-endian RSA public exponent (`e`), normalized without leading zeroes. + exponent: Vec, + }, + /// EC public key (`curve_oid`, uncompressed point bytes). + Ec { + /// Named-curve OID from SubjectPublicKeyInfo parameters. + curve_oid: String, + /// Raw EC point bytes from SubjectPublicKeyInfo. + public_key: Vec, + }, + /// Public key algorithm is present but not parsed into a concrete key type. + Unsupported { + /// SubjectPublicKeyInfo algorithm OID. + algorithm_oid: String, + }, } /// Errors during XMLDSig element parsing. @@ -504,6 +552,8 @@ fn parse_x509_data_dispatch(node: Node) -> Result { ensure_x509_data_entry_budget(&info)?; let cert = decode_x509_base64(child, "X509Certificate")?; add_x509_data_usage(&mut total_binary_len, cert.len())?; + let parsed_cert = parse_x509_certificate(cert.as_slice())?; + info.parsed_certificates.push(parsed_cert); info.certificates.push(cert); } (Some(XMLDSIG_NS), "X509SubjectName") => { @@ -631,6 +681,80 @@ fn decode_x509_base64( Ok(decoded) } +fn parse_x509_certificate(cert_der: &[u8]) -> Result { + let (rest, cert) = + x509_parser::certificate::X509Certificate::from_der(cert_der).map_err(|err| { + ParseError::InvalidStructure(format!("X509Certificate is not valid DER X.509: {err}")) + })?; + if !rest.is_empty() { + return Err(ParseError::InvalidStructure( + "X509Certificate contains trailing bytes after DER certificate".into(), + )); + } + + let subject_dn = cert.subject().to_string(); + let issuer_dn = cert.issuer().to_string(); + + let subject_key_identifier = cert.extensions().iter().find_map(|ext| { + if let ParsedExtension::SubjectKeyIdentifier(ski) = ext.parsed_extension() { + Some(ski.0.to_vec()) + } else { + None + } + }); + + let spki = cert.public_key(); + let public_key = match spki.parsed().map_err(|err| { + ParseError::InvalidStructure(format!("X509Certificate public key parse error: {err}")) + })? { + PublicKey::RSA(rsa) => { + let modulus = trim_leading_zeroes(rsa.modulus); + let exponent = trim_leading_zeroes(rsa.exponent); + if modulus.is_empty() || exponent.is_empty() { + return Err(ParseError::InvalidStructure( + "X509Certificate RSA key contains empty modulus or exponent".into(), + )); + } + X509PublicKeyInfo::Rsa { modulus, exponent } + } + PublicKey::EC(ec_point) => { + let Some(params) = spki.algorithm.parameters.as_ref() else { + return Err(ParseError::InvalidStructure( + "X509Certificate EC key is missing curve parameters".into(), + )); + }; + + match params.as_oid() { + Ok(oid) => X509PublicKeyInfo::Ec { + curve_oid: oid.to_id_string(), + public_key: ec_point.data().to_vec(), + }, + Err(_) => X509PublicKeyInfo::Unsupported { + algorithm_oid: spki.algorithm.algorithm.to_id_string(), + }, + } + } + _ => X509PublicKeyInfo::Unsupported { + algorithm_oid: spki.algorithm.algorithm.to_id_string(), + }, + }; + + Ok(ParsedX509Certificate { + subject_dn, + issuer_dn, + subject_key_identifier, + public_key, + }) +} + +fn trim_leading_zeroes(bytes: &[u8]) -> Vec { + let first_non_zero = bytes + .iter() + .position(|byte| *byte != 0) + .unwrap_or(bytes.len()); + bytes[first_non_zero..].to_vec() +} + fn parse_x509_issuer_serial(node: Node<'_, '_>) -> Result<(String, String), ParseError> { verify_ds_element(node, "X509IssuerSerial")?; ensure_no_non_whitespace_text(node, "X509IssuerSerial")?; @@ -845,6 +969,13 @@ mod tests { use super::*; use base64::Engine; + fn fixture_rsa_cert_base64() -> String { + include_str!("../../tests/fixtures/keys/rsa/rsa-2048-cert.pem") + .lines() + .filter(|line| !line.starts_with("-----")) + .collect::() + } + // ── SignatureAlgorithm ─────────────────────────────────────────── #[test] @@ -937,7 +1068,9 @@ mod tests { #[test] fn parse_key_info_dispatches_supported_children() { - let xml = r#" idp-signing-key @@ -947,7 +1080,7 @@ mod tests { - AQID + {cert_base64} CN=Example CN=CA @@ -958,8 +1091,9 @@ mod tests { CAkK AQIDBA== - "#; - let doc = Document::parse(xml).unwrap(); + "# + ); + let doc = Document::parse(&xml).unwrap(); let key_info = parse_key_info(doc.root_element()).unwrap(); assert_eq!(key_info.sources.len(), 4); @@ -972,20 +1106,38 @@ mod tests { key_info.sources[1], KeyInfoSource::KeyValue(KeyValueInfo::RsaKeyValue) ); + let x509_info = match &key_info.sources[2] { + KeyInfoSource::X509Data(x509) => x509, + other => panic!("expected X509Data source, got {other:?}"), + }; + let expected_cert = base64::engine::general_purpose::STANDARD + .decode(&cert_base64) + .expect("fixture PEM must contain valid base64"); + assert_eq!(x509_info.certificates, vec![expected_cert]); + assert_eq!(x509_info.subject_names, vec!["CN=Example".to_string()]); assert_eq!( - key_info.sources[2], - KeyInfoSource::X509Data(X509DataInfo { - certificates: vec![vec![1, 2, 3]], - subject_names: vec!["CN=Example".into()], - issuer_serials: vec![("CN=CA".into(), "42".into())], - skis: vec![vec![1, 2, 3, 4]], - crls: vec![vec![4, 5, 6, 7]], - digests: vec![( - "http://www.w3.org/2001/04/xmlenc#sha256".into(), - vec![8, 9, 10] - )], - }) + x509_info.issuer_serials, + vec![("CN=CA".to_string(), "42".to_string())] ); + assert_eq!(x509_info.skis, vec![vec![1, 2, 3, 4]]); + assert_eq!(x509_info.crls, vec![vec![4, 5, 6, 7]]); + assert_eq!( + x509_info.digests, + vec![( + "http://www.w3.org/2001/04/xmlenc#sha256".to_string(), + vec![8, 9, 10] + )] + ); + assert_eq!(x509_info.parsed_certificates.len(), 1); + let parsed_cert = &x509_info.parsed_certificates[0]; + assert!(!parsed_cert.subject_dn.is_empty()); + assert!(!parsed_cert.issuer_dn.is_empty()); + assert!(parsed_cert.subject_key_identifier.is_some()); + assert!(matches!( + parsed_cert.public_key, + X509PublicKeyInfo::Rsa { .. } + )); + assert_eq!( key_info.sources[3], KeyInfoSource::DerEncodedKeyValue(vec![1, 2, 3, 4]) @@ -1202,12 +1354,12 @@ mod tests { #[test] fn parse_key_info_rejects_x509_data_exceeding_total_binary_budget() { let payload = base64::engine::general_purpose::STANDARD.encode(vec![0u8; 190_000]); - let certs = (0..6) - .map(|_| format!("{payload}")) + let entries = (0..6) + .map(|_| format!("{payload}")) .collect::>() .join(""); let xml = format!( - "{certs}" + "{entries}" ); let doc = Document::parse(&xml).unwrap(); @@ -1215,6 +1367,47 @@ mod tests { assert!(matches!(err, ParseError::InvalidStructure(_))); } + #[test] + fn parse_key_info_rejects_x509_certificate_with_invalid_der() { + let xml = r#" + + AQID + + "#; + let doc = Document::parse(xml).unwrap(); + + let err = parse_key_info(doc.root_element()).unwrap_err(); + assert!(matches!(err, ParseError::InvalidStructure(_))); + } + + #[test] + fn parse_key_info_marks_unsupported_spki_algorithm_as_unsupported() { + let xml = include_str!( + "../../tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-x509-crt.xml" + ); + let doc = Document::parse(xml).unwrap(); + let key_info_node = doc + .descendants() + .find(|node| { + node.is_element() + && node.tag_name().namespace() == Some(XMLDSIG_NS) + && node.tag_name().name() == "KeyInfo" + }) + .expect("fixture must contain ds:KeyInfo"); + + let key_info = parse_key_info(key_info_node).expect("KeyInfo parse should succeed"); + let x509_info = match &key_info.sources[0] { + KeyInfoSource::X509Data(x509) => x509, + other => panic!("expected X509Data source, got {other:?}"), + }; + assert_eq!(x509_info.certificates.len(), 1); + assert_eq!(x509_info.parsed_certificates.len(), 1); + assert!(matches!( + x509_info.parsed_certificates[0].public_key, + X509PublicKeyInfo::Unsupported { .. } + )); + } + #[test] fn parse_key_info_accepts_large_textual_x509_entries_within_entry_budget() { let issuer_name = "C".repeat(MAX_X509_ISSUER_NAME_TEXT_LEN);