diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c5c92c1..6adbc27e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,7 +71,7 @@ jobs: xml-backend: fat-runtime cargo-args: --no-default-features --features xmldsig,xmlenc,c14n,xml-backends-all - rust: stable - xml-backend: xmlenc-only + xml-backend: xmlenc-inventory cargo-args: --no-default-features --features xmlenc,xml-backend-xmloxide - rust: "1.92.0" xml-backend: xmloxide diff --git a/Cargo.toml b/Cargo.toml index f1918003..bb48f210 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,6 +106,8 @@ cbc = { version = "0.2.1", optional = true } des = { version = "0.9", optional = true } md-5 = { version = "0.11", optional = true } pem = { version = "4", optional = true } +pkcs12 = { version = "0.2.0-pre.0", default-features = false, features = ["kdf"], optional = true } +pbkdf2 = { version = "0.13", default-features = false, features = ["hmac"], optional = true } # X.509 certificates x509-parser = { version = "0.18", features = ["verify"], optional = true } @@ -152,6 +154,10 @@ xmldsig = [ # XML Digital Signatures (sign + verify) "dep:hmac", "dep:md-5", "dep:pem", + "dep:pkcs12", + "dep:pbkdf2", + "dep:aes", + "dep:cbc", "dep:peresil", "dep:p256", "dep:p384", @@ -171,6 +177,8 @@ xmldsig = [ # XML Digital Signatures (sign + verify) ] xmlenc = [ # XML Encryption (encrypt + decrypt) "std", + # The shared key inventory stores XMLDSig KeyInfo for named RSA recipients. + "xmldsig", "dep:aes", "dep:aes-gcm", "dep:aes-kw", diff --git a/README.md b/README.md index ed94d7d9..37e2b310 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ xml-sec = { version = "0.1", default-features = false, features = ["xmldsig", "c | XML signatures | XMLDSig signing and verification, RSA/DSA/ECDSA/HMAC, XPath transforms, `Manifest`, `KeyInfo`, and caller-provided references | | XML encryption | AES-CBC/GCM, RSA-OAEP, AES Key Wrap, multiple recipients, and Element/Content replacement | | X.509 | Certificate key extraction, chain validation, CRLs, and policy-controlled trust | +| Key management | Caller-owned named inventory, usage-restricted keys, xmlsec `keys.xml`, encrypted PKCS#8, and bounded RustCrypto-backed PKCS#12 import | | SAML 2.0 | Signed assertions and encrypted-assertion workflows covered by integration tests | | XML input | Strict bounded byte decoding, entity/depth/node limits, stable node identities, and generation-safe mutation | | Crypto | Provider-neutral contracts and opaque key handles with pure-Rust RustCrypto as the default implementation | @@ -83,6 +84,8 @@ The signing and verification pipelines support same-document and caller-provided XPath 1.0 and XPath Filter 2 transforms, `Manifest`, structured `KeyInfo`, and policy-controlled X.509 validation. See [XML Digital Signatures](docs/xmldsig.md) for algorithms, transform semantics, key resolution, failure handling, and current interoperability boundaries. +See [Key management](docs/key-management.md) for inventory ownership, format import, +password handling, and CLI key-store behavior. ## XML Encryption diff --git a/docs/cli.md b/docs/cli.md index 43693f7a..1c217617 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -185,6 +185,16 @@ headers, or DER structure select encrypted versus plain decoding first: a supplied password is ignored for a plain key, while a missing or wrong password for an encrypted key fails without a plaintext fallback, before output is committed, and is never included in diagnostics. +`--keys-file FILE` imports a bounded xmlsec `keys.xml` store for sign, verify, +encrypt, and decrypt. Named HMAC/DSA signers, named HMAC/RSA/EC public +verification keys, direct AES content keys, and RSA-OAEP recipients are selected +from the same caller-owned inventory; an imported key never bypasses the +operation policy. `--pkcs12[:NAME] FILE --pwd PASSWORD` supplies one private +key and its certificate chain for sign or RSA decryption. Bundles with multiple +private keys are rejected rather than selecting an arbitrary bag. Neither +option triggers network lookup or implicit key discovery. See +[Key management](key-management.md) for the byte-oriented library API and trust +model. Verification accepts `-` as the conventional stdin marker. Verification starts at the document root and uses the first descendant `Signature` in document order. diff --git a/docs/key-management.md b/docs/key-management.md new file mode 100644 index 00000000..1d947b7c --- /dev/null +++ b/docs/key-management.md @@ -0,0 +1,140 @@ +# Key management + +`xml_sec::key_manager::KeyInventory` is a caller-owned inventory of named key +material. The library imports bytes supplied by the caller; it never discovers +files, reads the environment, or fetches network resources. Applications keep +the inventory for as long as its keys are needed and pass the selected signer or +resolver to the normal XMLDSig/XMLEnc operation context. Import and execution +are both bounded by the operation's `ResourcePolicy` and cryptographic policy. +`KeyInventory::from_xml_bytes` accepts the same signing, verification, +encryption, or decryption policy snapshot used by the operation, so XML parser +allowances and resource limits cannot diverge. `decryption_resolver` also +requires the decryption snapshot and checks selected key material before +copying or decoding it. A permitted document `KeyName` may select a caller-owned +public key even when document-supplied key bytes are disabled; all sources in +the document's original `KeyInfo` remain subject to the source policy. +The `xmlenc` Cargo feature also enables `xmldsig`: the shared inventory uses +XMLDSig `KeyInfo` to represent named public recipients. Thus the inventory API +is available when an application selects `xmlenc` and an XML backend without +separately naming `xmldsig`. + +The inventory accepts raw HMAC and AES secrets, public SPKI DER or PEM (also +PKCS#1 RSA public PEM), private PKCS#8 DER or PEM (including password-protected +PKCS#8), RSA PKCS#1 private DER or PEM, PKCS#12 bundles, DER X.509 certificates +and CRLs, and libxmlsec1 `keys.xml` bytes. The `keys.xml` importer recognizes +HMAC, AES, RSA, EC, and libxmlsec1's private DSA extension. DES key entries +are rejected because this build has no DES encryption operation. Unknown +algorithms in a mixed xmlsec key store are skipped; malformed supported entries +and ambiguous names fail. A PKCS#12 bundle with more than one private key is +rejected rather than assigning arbitrary aliases. A matching leaf certificate +is retained with its imported private key; byte-identical duplicate leaf bags +count as one certificate. Other certificates are retained as +untrusted chain material. `matching_certificate_chain()` returns a chain only +when its first certificate matches the private key; a CA-only PKCS#12 bundle +can still sign without emitting an unrelated signing certificate. A private +bundle's certificates do not become verification lookup candidates implicitly; +register a certificate explicitly for lookup or trust when needed. + +Each imported key has an explicit `KeyUsages` set. For example, a key registered +for `Verify` cannot sign, and an `Encrypt`-only key cannot decrypt. Imported +public and PKCS#12 keys can be restricted at import with +`add_public_der_with_usages`, `add_public_pem_with_usages`, and +`add_pkcs12_with_usages`; the shorter methods authorize only operations +supported by that key family. An EC/DSA private key may sign but cannot be +assigned RSA decryption usage. Incompatible or empty usage sets are rejected. +EC and DSA public keys can verify but cannot be authorized as RSA encryption +recipients, including when imported from `keys.xml`. Imported certificates +are lookup candidates, **not trust anchors**, unless the caller +explicitly registers them as trusted. The operation's immutable policy still +decides algorithm acceptance, key minima, certificate validation, CRL checks, +and resource limits. An imported key is never permission to bypass that policy. +Caller-provided key names are bounded before import and charged to the retained +material budget for every stored copy, including public-key `KeyName` metadata. +Selection methods return `KeyStoreError::Policy` for operation-policy denials, +distinct from candidate-local `KeyStoreError::Selection` failures. Callers +must not retry another key after a policy rejection. +An already-selected public entry can expose its RSA recipient key directly via +`StoredPublicKey::rsa_encryption_key(&encryption_policy)` without a second +inventory name lookup. The operation policy is required so source sizes are +checked before RSA decoding. Direct and XML key-store imports accept only +16-, 24-, or 32-byte AES keys; unsupported public-key algorithms are rejected +at import rather than acquiring verification permission. +Public DSA entries must contain independently usable parameters; the inventory +does not infer missing parameters from another entry. Verification validates the +complete policy snapshot before selecting or copying any key, including HMAC. +EC SPKI and certificate imports use the verifier's uncompressed SEC1 profile; +compressed points are rejected before granting verification usage. Each complete +KeyValue is one resource for selection limits, not one resource per component. +When a named certificate is selected, enabled CRL checking retains both inventory +and document CRLs, with their combined resource budget checked before copying. + +`add_private_der_with_password_callback` asks the caller for a zeroizing byte +password only for encrypted PKCS#8; plaintext input does not invoke it. +`add_pkcs12_with_password_callback` obtains a zeroizing string password before +decoding the bundle, after checking encoded size, visible bag/container counts, +and all visible MAC/encryption KDF parameters against one aggregate work budget. +KDF parameters inside encrypted SafeContents cannot be inspected without the +password: they are checked immediately after outer decryption, before running +the inner derivation (RFC 7292 sections 4.1 and 4.2.2). A missing +or wrong password returns a redacted error and never +triggers an unprotected fallback. Oversized encoded bundles return a typed +resource-policy error without invoking the callback. +`ResourcePolicy::max_key_import_kdf_work` and +`max_key_import_kdf_memory_bytes` can tighten protected-key derivation; both +are capped by implementation safety ceilings and checked before decryption. +Exceeding a recognized KDF's work or memory limit returns a policy error; +missing or incorrect passwords remain protected-container errors. +The CLI applies the same pre-decryption KDF limits to explicit protected PKCS#8 +PEM/DER keys, including the generic private-key options, as to inventory imports. +When the PKCS#12 parser rejects an oversized salt, that distinct resource +rejection also returns a typed policy error. +The importer uses RustCrypto primitives with borrowed BER views; it supports +PBES2/PBKDF2 with AES-CBC and legacy SHA-1/3DES containers, plus SHA-1/SHA-2 MACs. +Private keys and decrypted temporary buffers are zeroized. Nested safe bags +share the same candidate and KDF budgets; a count denial is not a password error. +Temporary import allocations share the aggregate allowance with material already +retained by the inventory, and KDF workspaces are checked before derivation. +Named direct AES keys participate only in direct content-key resolution, not +in recipient-key unwrapping, so recipient hints cannot duplicate their candidate. + +```rust +use xml_sec::key_manager::{KeyInventory, KeyUsages, SymmetricKeyKind}; +use xml_sec::policy::ResourcePolicy; + +let mut keys = KeyInventory::default(); +keys.add_symmetric( + "signer".into(), + SymmetricKeyKind::Hmac, + b"caller-owned-secret".to_vec(), + KeyUsages::SIGN, + &ResourcePolicy::default(), +)?; +``` + +The CLI is the explicit file-I/O compatibility boundary. `xmlsec1 +sign|verify|encrypt|decrypt --keys-file keys.xml` loads one or more bounded +xmlsec key stores. `sign` and `decrypt` also accept `--pkcs12[:NAME] file.p12 +--pwd PASSWORD`; password handling happens before protected-key decoding, and +a wrong or missing password fails without a plaintext fallback. Key files are +not silently combined with conflicting explicit key options. `KeyName` in a +signature or encryption template selects the corresponding inventory entry; +distinct matches are ambiguous unless the caller explicitly requests the CLI's +compatibility search mode. The CLI uses the same signing, verification, +encryption, and decryption policy checks as direct key options. During +decryption, a named direct AES key from `--keys-file` can be selected even +when `EncryptedData` also contains an `EncryptedKey` recipient. +For multiple RSA encryption recipients, `--lax-key-search` prefers an exact +name and then tries remaining compatible entries in store order. Each selected +entry is consumed once for that operation; insufficient entries fail before +any encrypted output is written. +Entries explicitly named by later recipient slots are reserved before assigning +fallbacks only when they match that slot's key metadata. An unnamed slot cannot +consume a later compatible exact match, but a stale name contradicted by metadata +does not reserve an incompatible key. +Reservation retains a decoded matching RSA candidate. Assignment moves that +candidate from the cache without decoding or charging it again; the candidate +work limit counts actual inspections, not reuse of an already inspected key. + +For production applications, do not put passwords on a process command line: +load them through the application's secret channel and call the byte-oriented +library import API instead. diff --git a/src/document.rs b/src/document.rs index e560b0e6..8632b638 100644 --- a/src/document.rs +++ b/src/document.rs @@ -5,6 +5,7 @@ //! generation atomically, so identities from an older generation cannot be //! confused with nodes in the new tree. +use std::borrow::Cow; #[cfg(any(feature = "xmldsig", feature = "xmlenc"))] use std::cell::Cell; use std::collections::{HashMap, HashSet, hash_map::Entry}; @@ -3303,6 +3304,14 @@ fn decode_owned_xml( maximum: usize, budget: Option<&XmlParseWorkBudget>, ) -> Result { + decode_xml_with_budget(bytes, maximum, budget).map(Cow::into_owned) +} + +pub(crate) fn decode_xml_with_budget<'a>( + bytes: &'a [u8], + maximum: usize, + budget: Option<&XmlParseWorkBudget>, +) -> Result, XmlDocumentError> { if bytes.len() > maximum { return Err(XmlDocumentError::DocumentTooLarge { maximum, @@ -3313,14 +3322,12 @@ fn decode_owned_xml( // Charge it before encoding detection/transcoding and retain that charge // in the same sticky budget used by preflight and semantic construction. charge_parse_work(budget, bytes.len())?; - xml_sec_xml_input::decode_xml_bounded(bytes, None, maximum) - .map(|xml| xml.into_owned()) - .map_err(|error| match error { - xml_sec_xml_input::Error::DecodedLimit { actual, .. } => { - XmlDocumentError::DocumentTooLarge { maximum, actual } - } - error => XmlDocumentError::Encoding(error), - }) + xml_sec_xml_input::decode_xml_bounded(bytes, None, maximum).map_err(|error| match error { + xml_sec_xml_input::Error::DecodedLimit { actual, .. } => { + XmlDocumentError::DocumentTooLarge { maximum, actual } + } + error => XmlDocumentError::Encoding(error), + }) } fn allocate_document_identity(counter: &AtomicU64) -> Result { diff --git a/src/hard_limits.rs b/src/hard_limits.rs index de360c22..bb5f72ef 100644 --- a/src/hard_limits.rs +++ b/src/hard_limits.rs @@ -56,6 +56,17 @@ pub(crate) const ENCRYPTION_RECIPIENT_CEILING: usize = 64; /// Maximum symmetric keys attempted by one prepared decryption operation. #[cfg(any(feature = "xmldsig", feature = "xmlenc"))] pub(crate) const KEY_CANDIDATE_CEILING: usize = 64; +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] +pub(crate) const KEY_IMPORT_KDF_WORK_CEILING: u64 = 10_000_000; +/// Maximum workspace reserved by one imported password key derivation. +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] +pub(crate) const KEY_IMPORT_KDF_MEMORY_CEILING: usize = 32 * 1024 * 1024; +/// Stack-safety ceiling for BER construction and nested PKCS#12 safe bags. +#[cfg(feature = "xmldsig")] +pub(crate) const PKCS12_NESTING_CEILING: usize = 32; +/// Maximum byte length of one imported DSA integer before big-integer work. +#[cfg(feature = "xmldsig")] +pub(crate) const DSA_KEY_COMPONENT_BYTE_CEILING: usize = 512; /// Maximum nested `KeyInfoReference` dereference depth. #[cfg(any(feature = "xmldsig", feature = "xmlenc"))] pub(crate) const KEY_INFO_REFERENCE_DEPTH_CEILING: usize = 8; diff --git a/src/key_manager.rs b/src/key_manager.rs new file mode 100644 index 00000000..6736b884 --- /dev/null +++ b/src/key_manager.rs @@ -0,0 +1,5046 @@ +//! Caller-owned, provider-neutral key inventory and xmlsec key-store import. + +use std::collections::HashSet; + +use base64::Engine as _; +use crypto_bigint::{ + BoxedUint, + modular::{BoxedMontyForm, BoxedMontyParams}, +}; +use der::Decode as _; +use dsa::{ + Components as DsaComponents, SigningKey as NativeDsaSigningKey, + VerifyingKey as DsaVerifyingKey, pkcs8::EncodePrivateKey as _, +}; +mod pkcs12_import; +use pkcs12_import::Limits as Pkcs12Limits; +#[cfg(feature = "xmlenc")] +use rsa::pkcs8::DecodePublicKey as _; +use rsa::{ + RsaPrivateKey, RsaPublicKey, + pkcs1::{DecodeRsaPrivateKey as _, DecodeRsaPublicKey as _}, + pkcs8::{ + DecodePrivateKey as _, EncodePublicKey as _, EncryptedPrivateKeyInfoRef, PrivateKeyInfoRef, + }, +}; +use x509_parser::prelude::{FromDer as _, X509Certificate}; +use zeroize::Zeroizing; + +#[cfg(feature = "xmlenc")] +use crate::xmldsig::parse::X509PublicKeyInfo; +use crate::{ + XmlBackend, XmlDomNode as Node, + document::{ + DocumentParseSettings, XmlParseWorkBudget, parse_borrowed_with_settings_and_budget, + }, + policy::ResourcePolicy, + xmldsig::keys::InspectedKeyCandidateBudget, + xmldsig::parse::XMLDSIG11_NS, + xmldsig::{ + DefaultKeyResolver, DsaSigningKey, DsigError, EcdsaP256SigningKey, EcdsaP384SigningKey, + EcdsaP521SigningKey, HmacSigningKey, HmacVerificationKey, KeyInfo, KeyInfoSource, + KeyResolver, KeyResolverConfig, KeyValueInfo, RsaSigningKey, SignatureAlgorithm, + SigningKey, VerifyingKey, X509DataInfo, parse_key_info, validate_signing_key, + }, +}; + +const XMLSEC_NS: &str = "http://www.aleksey.com/xmlsec/2002"; +const XMLDSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#"; + +fn check_selected_public_material( + info: &KeyInfo, + resources: &ResourcePolicy, +) -> Result { + let mut total = 0_usize; + for source in &info.sources { + let mut charge = |length: usize| -> Result<(), DsigError> { + if length > resources.max_external_resource_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_bytes, + actual: length, + } + .into()); + } + total = total.checked_add(length).ok_or({ + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_total_bytes, + actual: usize::MAX, + } + })?; + if total > resources.max_external_resource_total_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_total_bytes, + actual: total, + } + .into()); + } + Ok(()) + }; + match source { + KeyInfoSource::KeyValue(value) => { + // One selected key is one resource, irrespective of how many + // XML fields encode it. Bound the complete borrowed payload + // before resolution materializes its SPKI. + let lengths = match value { + KeyValueInfo::Rsa { modulus, exponent } => { + [modulus.len(), exponent.len(), 0, 0] + } + KeyValueInfo::Dsa { p, q, g, y } => [ + p.as_ref().map_or(0, Vec::len), + q.as_ref().map_or(0, Vec::len), + g.as_ref().map_or(0, Vec::len), + y.len(), + ], + KeyValueInfo::Ec { + curve_oid, + public_key, + } => [curve_oid.len(), public_key.len(), 0, 0], + KeyValueInfo::InvalidEcKeyValue | KeyValueInfo::Unsupported { .. } => continue, + }; + let length = lengths.into_iter().try_fold(0_usize, |sum, length| { + sum.checked_add(length) + .ok_or(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_bytes, + actual: usize::MAX, + }) + })?; + charge(length)?; + } + KeyInfoSource::DerEncodedKeyValue(bytes) => charge(bytes.len())?, + KeyInfoSource::X509Data(data) => { + for certificate in &data.certificates { + charge(certificate.len())?; + } + for crl in &data.crls { + charge(crl.len())?; + } + } + _ => {} + } + } + Ok(total) +} + +/// A named secret imported from an xmlsec key store. +pub struct StoredSymmetricKey { + /// Opaque caller-supplied lookup name. + pub name: String, + /// XML Security symmetric-key family. + pub kind: SymmetricKeyKind, + /// Secret bytes, zeroized when the inventory is dropped. + pub bytes: Zeroizing>, + /// Allowed operations. + pub usages: KeyUsages, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// The secret-key family declared by an xmlsec key store. +pub enum SymmetricKeyKind { + /// HMAC signing and verification key. + Hmac, + /// AES content-encryption key. + Aes, + /// Legacy DES key marker; import rejects it until a DES operation exists. + Des, +} + +#[derive(Default)] +/// A caller-owned inventory of imported XML Security key material. +pub struct KeyInventory { + /// Total XML entries inspected, including unsupported algorithms. + entry_count: usize, + /// Supported symmetric keys. + symmetric_keys: Vec, + /// Supported public keys. + public_keys: Vec, + /// Private PKCS#8 keys imported from caller-owned byte sources. + private_keys: Vec, + /// Untrusted certificates available for key lookup or path construction. + lookup_certificates: Vec>, + /// Explicit caller-trusted certificate anchors. + trusted_certificates: Vec>, + /// Caller-supplied DER certificate revocation lists. + crls: Vec>, + material_bytes: usize, +} + +/// Candidate inspections shared by named signing lookups in one operation. +#[derive(Default)] +pub struct SigningLookupBudget { + inspected: usize, +} + +/// Operations for which a caller may authorize a key. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum KeyUsage { + /// XMLDSig signing. + Sign, + /// XMLDSig verification. + Verify, + /// XMLEnc encryption or key wrapping. + Encrypt, + /// XMLEnc decryption or key unwrapping. + Decrypt, +} + +/// Explicit, immutable allowed-use set for one imported key. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct KeyUsages(u8); + +impl KeyUsages { + /// A key usable only for signing. + pub const SIGN: Self = Self(1); + /// A key usable only for verification. + pub const VERIFY: Self = Self(2); + /// A key usable only for encryption. + pub const ENCRYPT: Self = Self(4); + /// A key usable only for decryption. + pub const DECRYPT: Self = Self(8); + + /// Combine disjoint permissions explicitly. + #[must_use] + pub const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + /// Test one requested operation. + #[must_use] + pub const fn allows(self, usage: KeyUsage) -> bool { + let bit = match usage { + KeyUsage::Sign => Self::SIGN.0, + KeyUsage::Verify => Self::VERIFY.0, + KeyUsage::Encrypt => Self::ENCRYPT.0, + KeyUsage::Decrypt => Self::DECRYPT.0, + }; + self.0 & bit != 0 + } +} + +/// Private key encoded as provider-neutral PKCS#8 DER. +pub struct StoredPrivateKey { + /// Opaque caller-assigned name. + pub name: String, + /// Private key bytes; zeroized on drop. + pub pkcs8_der: Zeroizing>, + /// Allowed operations. + pub usages: KeyUsages, + /// Associated certificates, leaf first when a matching leaf exists. + pub certificate_chain: Vec>, + has_matching_leaf: bool, +} + +impl StoredPrivateKey { + /// Return the chain only when its first certificate matches this private key. + #[must_use] + pub fn matching_certificate_chain(&self) -> Option<&[Vec]> { + self.has_matching_leaf.then_some(&self.certificate_chain) + } +} + +/// A named public key imported from an xmlsec key store. +pub struct StoredPublicKey { + /// Opaque caller-supplied lookup name. + pub name: String, + /// Parsed XMLDSig key material. + pub key_info: KeyInfo, + /// Allowed operations. + pub usages: KeyUsages, +} + +impl StoredPublicKey { + /// Decode this already-selected RSA recipient without searching the inventory again. + #[cfg(feature = "xmlenc")] + pub fn rsa_encryption_key( + &self, + policy: &crate::policy::EncryptionPolicy, + ) -> Result { + policy.validate()?; + if !self.usages.allows(KeyUsage::Encrypt) { + return Err(KeyStoreError::Selection( + "key is not authorized for encryption", + )); + } + for source in &self.key_info.sources { + return match source { + KeyInfoSource::KeyValue(KeyValueInfo::Rsa { modulus, exponent }) => { + rsa_recipient_from_components(modulus, exponent, policy) + } + KeyInfoSource::DerEncodedKeyValue(der) => { + check_encryption_material_size(der.len(), &policy.resources)?; + let (rest, spki) = x509_parser::x509::SubjectPublicKeyInfo::from_der(der) + .map_err(|_| KeyStoreError::Selection("invalid RSA encryption key"))?; + if !rest.is_empty() { + return Err(KeyStoreError::Selection("invalid RSA encryption key")); + } + let x509_parser::public_key::PublicKey::RSA(raw) = spki + .parsed() + .map_err(|_| KeyStoreError::Selection("invalid RSA encryption key"))? + else { + return Err(KeyStoreError::Selection( + "named key is not an RSA public key", + )); + }; + rsa_recipient_preflight(raw.modulus, raw.exponent, policy)?; + RsaPublicKey::from_public_key_der(der) + .map_err(|_| KeyStoreError::Selection("invalid RSA encryption key")) + } + KeyInfoSource::X509Data(data) if data.parsed_certificates.len() == 1 => { + if let Some(certificate) = data.certificates.first() { + check_encryption_material_size(certificate.len(), &policy.resources)?; + } + match &data.parsed_certificates[0].public_key { + X509PublicKeyInfo::Rsa { modulus, exponent } => { + rsa_recipient_from_components(modulus, exponent, policy) + } + _ => Err(KeyStoreError::Selection("certificate does not contain RSA")), + } + } + _ => continue, + }; + } + Err(KeyStoreError::Selection( + "named key is not an RSA public key", + )) + } +} + +/// Policy-aware verification adapter over a caller-owned inventory. +pub struct InventoryVerificationResolver<'a> { + inventory: &'a KeyInventory, +} + +impl<'a> KeyResolver for InventoryVerificationResolver<'a> { + fn resolve<'k>( + &'k self, + key_info: Option<&KeyInfo>, + algorithm: SignatureAlgorithm, + ) -> Result>, DsigError> { + self.resolve_with_policy_and_provider( + key_info, + algorithm, + &crate::policy::VerificationPolicy::default(), + crate::provider::default_provider(), + ) + } + + fn resolve_with_policy<'k>( + &'k self, + key_info: Option<&KeyInfo>, + algorithm: SignatureAlgorithm, + policy: &crate::policy::VerificationPolicy, + ) -> Result>, DsigError> { + self.resolve_with_policy_and_provider( + key_info, + algorithm, + policy, + crate::provider::default_provider(), + ) + } + + fn resolve_with_policy_and_provider<'k>( + &'k self, + key_info: Option<&KeyInfo>, + algorithm: SignatureAlgorithm, + policy: &crate::policy::VerificationPolicy, + provider: &dyn crate::provider::CryptoProvider, + ) -> Result>, DsigError> { + policy.validate()?; + if let Some(info) = key_info { + crate::xmldsig::keys::validate_key_info_source_permissions(info, policy.key_sources)?; + } + let mut candidate: Option<&StoredPublicKey> = None; + let mut secret_candidate: Option<&StoredSymmetricKey> = None; + let mut inspected_candidates = + InspectedKeyCandidateBudget::new(policy.resources.max_key_candidates); + for name in key_info + .into_iter() + .flat_map(|info| &info.sources) + .filter_map(|source| match source { + KeyInfoSource::KeyName(name) => Some(name.as_str()), + _ => None, + }) + { + if self.inventory.symmetric_keys.is_empty() && self.inventory.public_keys.is_empty() { + inspected_candidates.charge()?; + } + let mut symmetric_match = None; + for entry in &self.inventory.symmetric_keys { + inspected_candidates.charge()?; + if entry.name == name { + symmetric_match = Some(entry); + break; + } + } + if let Some(found) = symmetric_match { + if !found.usages.allows(KeyUsage::Verify) || found.kind != SymmetricKeyKind::Hmac { + return Err(DsigError::InvalidStructure { + reason: "named key is not authorized for verification", + }); + } + if candidate.is_some() + || secret_candidate.is_some_and(|previous| !core::ptr::eq(previous, found)) + { + return Err(DsigError::InvalidStructure { + reason: "more than one named verification key matches", + }); + } + secret_candidate = Some(found); + continue; + } + let mut public_match = None; + for entry in &self.inventory.public_keys { + inspected_candidates.charge()?; + if entry.name == name { + public_match = Some(entry); + break; + } + } + if let Some(found) = public_match { + if !found.usages.allows(KeyUsage::Verify) { + return Err(DsigError::InvalidStructure { + reason: "named key is not authorized for verification", + }); + } + if secret_candidate.is_some() + || candidate.is_some_and(|previous| !core::ptr::eq(previous, found)) + { + return Err(DsigError::InvalidStructure { + reason: "more than one named verification key matches", + }); + } + candidate = Some(found); + } + } + if let Some(candidate) = secret_candidate { + if algorithm.hmac_output_bits().is_none() { + return Err(DsigError::InvalidStructure { + reason: "named HMAC key is incompatible with signature method", + }); + } + for (resource, maximum) in [ + ( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + policy.resources.max_external_resource_bytes, + ), + ( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + policy.resources.max_external_resource_total_bytes, + ), + ] { + if candidate.bytes.len() > maximum { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource, + maximum, + actual: candidate.bytes.len(), + } + .into()); + } + } + let key = HmacVerificationKey::new(candidate.bytes.to_vec()).map_err(|_| { + DsigError::InvalidStructure { + reason: "invalid named HMAC key", + } + })?; + return Ok(Some(Box::new(key))); + } + let selected_material_bytes = candidate + .map(|candidate| check_selected_public_material(&candidate.key_info, &policy.resources)) + .transpose()? + .unwrap_or(0); + let selected_info = candidate.map_or(key_info, |entry| Some(&entry.key_info)); + let configured_x509_index = selected_info.and_then(|info| { + info.sources.iter().position(|source| match source { + KeyInfoSource::X509Data(data) if data.certificate_chain.is_empty() => { + crate::xmldsig::parse::x509_data_has_lookup_identifiers(data) + } + KeyInfoSource::X509Data(_) => policy.key_trust.verify_x509_chains, + _ => false, + }) + }); + // Try only sources preceding the first configured-X.509 use without + // inspecting or copying inventory certificates that may never be used. + if let Some(info) = selected_info + && let Some(first_x509) = configured_x509_index + && first_x509 != 0 + { + let prefix_resolver = DefaultKeyResolver::new(KeyResolverConfig::default()); + let result = prefix_resolver.resolve_prefix_with_candidate_budget( + info, + algorithm, + policy, + provider, + &mut inspected_candidates, + if candidate.is_some() { + crate::xmldsig::keys::ResolutionScope::TrustedPrefix(first_x509) + } else { + crate::xmldsig::keys::ResolutionScope::DocumentPrefix(first_x509) + }, + ); + match result { + Ok(Some(key)) => return Ok(Some(key)), + Err(DsigError::Policy(violation)) => return Err(violation.into()), + _ => {} + } + } + let fallback = if configured_x509_index.is_some() { + let certificates = self + .inventory + .lookup_certificates + .iter() + .chain(&self.inventory.trusted_certificates); + // Selecting a trusted named key substitutes key material, not + // document revocation evidence. Retain CRLs without importing any + // document certificate into the trusted candidate's chain. + let document_crls = key_info + .filter(|_| { + candidate.is_some() + && policy.key_trust.check_crls + && policy.key_trust.verify_x509_chains + }) + .into_iter() + .flat_map(|info| &info.sources) + .filter_map(|source| match source { + KeyInfoSource::X509Data(data) => Some(data.crls.as_slice()), + _ => None, + }) + .flatten(); + let crls = self + .inventory + .crls + .iter() + .chain(document_crls) + .filter(|_| policy.key_trust.check_crls && policy.key_trust.verify_x509_chains); + let mut total = selected_material_bytes; + for material in certificates.chain(crls.clone()) { + if material.len() > policy.resources.max_external_resource_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum: policy.resources.max_external_resource_bytes, + actual: material.len(), + } + .into()); + } + debug_assert!(total <= policy.resources.max_external_resource_total_bytes); + if material.len() > policy.resources.max_external_resource_total_bytes - total { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + maximum: policy.resources.max_external_resource_total_bytes, + actual: total.saturating_add(material.len()), + } + .into()); + } + total += material.len(); + } + DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: self.inventory.lookup_certificates.clone(), + trusted_certs: self.inventory.trusted_certificates.clone(), + crls: crls.cloned().collect(), + ..KeyResolverConfig::default() + }) + } else { + DefaultKeyResolver::new(KeyResolverConfig::default()) + }; + if let Some(candidate) = candidate { + return fallback.resolve_trusted_material_with_candidate_budget( + &candidate.key_info, + algorithm, + policy, + provider, + &mut inspected_candidates, + ); + } + fallback.resolve_with_candidate_budget( + key_info, + algorithm, + policy, + provider, + &mut inspected_candidates, + ) + } + + fn consumes_document_key_info(&self) -> bool { + true + } +} + +enum ParsedMaterial { + Symmetric(SymmetricKeyKind, Zeroizing>), + Public(Option), + Dsa(KeyValueInfo, Option>>), + Unsupported, +} + +type ParsedDsaKey = (KeyValueInfo, Option>>); + +#[cfg(feature = "xmlenc")] +struct InventoryDirectAes(Zeroizing>); + +#[cfg(feature = "xmlenc")] +impl crate::xmlenc::DecryptionKeyResolver for InventoryDirectAes { + fn resolve_key( + &self, + _provider: &dyn crate::provider::CryptoProvider, + algorithm: crate::xmlenc::DataEncryptionAlgorithm, + encrypted_key: Option<&crate::xmlenc::EncryptedKey>, + ) -> Result, crate::xmlenc::XmlEncError> { + if encrypted_key.is_some() { + return Err(crate::xmlenc::XmlEncError::KeyNotFound); + } + crate::xmlenc::validate_key_len(algorithm, &self.0)?; + Ok(self.0.to_vec()) + } + + fn resolve_key_candidates( + &self, + provider: &dyn crate::provider::CryptoProvider, + algorithm: crate::xmlenc::DataEncryptionAlgorithm, + encrypted_key: Option<&crate::xmlenc::EncryptedKey>, + budget: &mut crate::xmlenc::KeyCandidateBudget, + ) -> Result>, crate::xmlenc::XmlEncError> { + // This inventory entry is a content key, not a transport key. + // Ineligible recipient paths neither copy it nor consume candidates. + if encrypted_key.is_some() { + return Err(crate::xmlenc::XmlEncError::KeyNotFound); + } + budget.consume(1)?; + self.resolve_key(provider, algorithm, None) + .map(|key| vec![key]) + } +} + +#[derive(Debug, thiserror::Error)] +/// Key-store import errors that never include secret material. +pub enum KeyStoreError { + /// The XML structure or key material was invalid. + #[error("invalid xmlsec keys.xml: {0}")] + Invalid(String), + /// The named key is missing, duplicated, or incompatible with the requested operation. + #[error("key inventory selection failed: {0}")] + Selection(&'static str), + /// The active operation policy rejected the key or its resources. + #[error("key inventory policy violation: {0}")] + Policy(#[from] crate::policy::PolicyViolation), + /// A protected container could not be decoded with the supplied password. + #[error("protected key container could not be decoded")] + ProtectedContainer, +} + +impl KeyInventory { + fn retained_material_bytes(&self) -> Result { + let mut total = 0_usize; + let mut add = |length: usize| -> Result<(), KeyStoreError> { + total = total + .checked_add(length) + .ok_or(KeyStoreError::Selection("key material size overflow"))?; + Ok(()) + }; + for key in &self.symmetric_keys { + add(key.name.len())?; + add(key.bytes.len())?; + } + for key in &self.private_keys { + add(key.name.len())?; + add(key.pkcs8_der.len())?; + for certificate in &key.certificate_chain { + add(certificate.len())?; + } + } + for key in &self.public_keys { + add(key.name.len())?; + for source in &key.key_info.sources { + match source { + KeyInfoSource::KeyName(name) => add(name.len())?, + KeyInfoSource::KeyValue(KeyValueInfo::Dsa { p, q, g, y }) => { + add(p.as_ref().map_or(0, Vec::len))?; + add(q.as_ref().map_or(0, Vec::len))?; + add(g.as_ref().map_or(0, Vec::len))?; + add(y.len())?; + } + KeyInfoSource::KeyValue(KeyValueInfo::Rsa { modulus, exponent }) => { + add(modulus.len())?; + add(exponent.len())?; + } + KeyInfoSource::KeyValue(KeyValueInfo::Ec { + curve_oid, + public_key, + }) => { + add(curve_oid.len())?; + add(public_key.len())?; + } + KeyInfoSource::DerEncodedKeyValue(bytes) => add(bytes.len())?, + _ => {} + } + } + } + for certificate in self + .lookup_certificates + .iter() + .chain(&self.trusted_certificates) + { + add(certificate.len())?; + } + for crl in &self.crls { + add(crl.len())?; + } + Ok(total) + } + + /// Number of imported candidates, including unsupported XML entries. + #[must_use] + pub fn entry_count(&self) -> usize { + self.entry_count + } + + /// Imported symmetric keys, without mutable access to inventory bounds. + #[must_use] + pub fn symmetric_keys(&self) -> &[StoredSymmetricKey] { + &self.symmetric_keys + } + + /// Imported public keys, without mutable access to inventory bounds. + #[must_use] + pub fn public_keys(&self) -> &[StoredPublicKey] { + &self.public_keys + } + + /// Imported private keys, without mutable access to inventory bounds. + #[must_use] + pub fn private_keys(&self) -> &[StoredPrivateKey] { + &self.private_keys + } + + /// Combine two caller-owned imports after checking aggregate bytes, + /// candidates, and cross-store name collisions before mutating either. + pub fn extend( + &mut self, + mut other: Self, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + ensure_resource_policy(resources)?; + let candidates = self + .entry_count + .checked_add(other.entry_count) + .ok_or(KeyStoreError::Selection("key candidate count overflow"))?; + if candidates > resources.max_key_candidates { + return Err(KeyStoreError::Selection("key candidate limit exceeded")); + } + let bytes = self + .material_bytes + .checked_add(other.material_bytes) + .ok_or(KeyStoreError::Selection("key material size overflow"))?; + if bytes > resources.max_external_resource_total_bytes { + return Err(KeyStoreError::Selection( + "key material total exceeds resource limit", + )); + } + let mut names = HashSet::new(); + for name in other + .symmetric_keys + .iter() + .map(|entry| entry.name.as_str()) + .chain(other.public_keys.iter().map(|entry| entry.name.as_str())) + .chain(other.private_keys.iter().map(|entry| entry.name.as_str())) + { + names.insert(name); + } + if names.iter().any(|name| { + self.symmetric_keys.iter().any(|entry| entry.name == *name) + || self.public_keys.iter().any(|entry| entry.name == *name) + || self.private_keys.iter().any(|entry| entry.name == *name) + }) { + return Err(KeyStoreError::Selection("duplicate key name")); + } + self.entry_count = candidates; + self.material_bytes = bytes; + self.symmetric_keys.append(&mut other.symmetric_keys); + self.public_keys.append(&mut other.public_keys); + self.private_keys.append(&mut other.private_keys); + self.lookup_certificates + .append(&mut other.lookup_certificates); + self.trusted_certificates + .append(&mut other.trusted_certificates); + self.crls.append(&mut other.crls); + Ok(()) + } + + /// Select an authorized named RSA recipient from XMLDSig RSAKeyValue or + /// DER SubjectPublicKeyInfo without introducing an implicit key source. + #[cfg(feature = "xmlenc")] + pub fn rsa_encryption_key( + &self, + name: &str, + policy: &crate::policy::EncryptionPolicy, + ) -> Result { + policy.validate()?; + let entry = find_named_entry( + &self.public_keys, + name, + &policy.resources, + &mut 0, + |entry| &entry.name, + )? + .ok_or(KeyStoreError::Selection("named encryption key not found"))?; + entry.rsa_encryption_key(policy) + } + + /// Select a named signer under the operation's immutable policy. + pub fn signing_key( + &self, + name: &str, + algorithm: SignatureAlgorithm, + policy: &crate::policy::SigningPolicy, + ) -> Result, KeyStoreError> { + self.signing_key_with_budget(name, algorithm, policy, &mut SigningLookupBudget::default()) + } + + /// Select a named signer while sharing lookup work across caller retries. + pub fn signing_key_with_budget( + &self, + name: &str, + algorithm: SignatureAlgorithm, + policy: &crate::policy::SigningPolicy, + budget: &mut SigningLookupBudget, + ) -> Result, KeyStoreError> { + policy.validate()?; + if algorithm.hmac_output_bits().is_some() { + let entry = find_named_entry( + &self.symmetric_keys, + name, + &policy.resources, + &mut budget.inspected, + |entry| &entry.name, + )? + .ok_or(KeyStoreError::Selection("named signing key not found"))?; + if entry.kind != SymmetricKeyKind::Hmac || !entry.usages.allows(KeyUsage::Sign) { + return Err(KeyStoreError::Selection( + "key is not authorized for signing", + )); + } + check_operation_material_size(entry.bytes.len(), &policy.resources)?; + let key = HmacSigningKey::new(entry.bytes.to_vec()) + .map_err(|_| KeyStoreError::Selection("invalid HMAC key"))?; + validate_signing_key(&key, algorithm, policy).map_err(signing_policy_error)?; + return Ok(Box::new(key)); + } + let entry = find_named_entry( + &self.private_keys, + name, + &policy.resources, + &mut budget.inspected, + |entry| &entry.name, + )? + .ok_or(KeyStoreError::Selection("named signing key not found"))?; + if !entry.usages.allows(KeyUsage::Sign) { + return Err(KeyStoreError::Selection( + "key is not authorized for signing", + )); + } + let der = entry.pkcs8_der.as_slice(); + check_operation_material_size(der.len(), &policy.resources)?; + if let Ok(info) = PrivateKeyInfoRef::try_from(der) + && info.algorithm.oid == dsa::OID + { + preflight_dsa_pkcs8_components(&info)?; + } + let key: Box = match algorithm { + SignatureAlgorithm::RsaSha1 + | SignatureAlgorithm::RsaSha224 + | SignatureAlgorithm::RsaSha256 + | SignatureAlgorithm::RsaSha384 + | SignatureAlgorithm::RsaSha512 => Box::new( + RsaSigningKey::from_pkcs8_der(der) + .map_err(|_| KeyStoreError::Selection("incompatible RSA signing key"))?, + ), + SignatureAlgorithm::DsaSha1 | SignatureAlgorithm::DsaSha256 => Box::new( + DsaSigningKey::from_pkcs8_der(der) + .map_err(|_| KeyStoreError::Selection("incompatible DSA signing key"))?, + ), + SignatureAlgorithm::EcdsaSha1 + | SignatureAlgorithm::EcdsaSha224 + | SignatureAlgorithm::EcdsaSha256 + | SignatureAlgorithm::EcdsaSha384 + | SignatureAlgorithm::EcdsaSha512 => { + if let Ok(key) = EcdsaP256SigningKey::from_pkcs8_der(der) { + Box::new(key) + } else if let Ok(key) = EcdsaP384SigningKey::from_pkcs8_der(der) { + Box::new(key) + } else { + Box::new( + EcdsaP521SigningKey::from_pkcs8_der(der) + .map_err(|_| KeyStoreError::Selection("incompatible EC signing key"))?, + ) + } + } + _ => return Err(KeyStoreError::Selection("unsupported signature method")), + }; + validate_signing_key(key.as_ref(), algorithm, policy).map_err(signing_policy_error)?; + Ok(key) + } + + /// Select a named direct AES key or RSA private-key transport resolver. + #[cfg(feature = "xmlenc")] + pub fn decryption_resolver( + &self, + name: &str, + policy: &crate::policy::DecryptionPolicy, + ) -> Result, KeyStoreError> { + policy.validate()?; + let mut visited = 0; + if let Some(entry) = find_named_entry( + &self.symmetric_keys, + name, + &policy.resources, + &mut visited, + |entry| &entry.name, + )? { + if entry.kind != SymmetricKeyKind::Aes || !entry.usages.allows(KeyUsage::Decrypt) { + return Err(KeyStoreError::Selection( + "key is not authorized for decryption", + )); + } + check_selected_material_size(entry.bytes.len(), &policy.resources)?; + return Ok(Box::new(InventoryDirectAes(Zeroizing::new( + entry.bytes.to_vec(), + )))); + } + let entry = find_named_entry( + &self.private_keys, + name, + &policy.resources, + &mut visited, + |entry| &entry.name, + )? + .ok_or(KeyStoreError::Selection("named decryption key not found"))?; + if !entry.usages.allows(KeyUsage::Decrypt) { + return Err(KeyStoreError::Selection( + "key is not authorized for decryption", + )); + } + check_selected_material_size(entry.pkcs8_der.len(), &policy.resources)?; + let key = RsaPrivateKey::from_pkcs8_der(&entry.pkcs8_der) + .map_err(|_| KeyStoreError::Selection("incompatible RSA decryption key"))?; + Ok(Box::new(crate::xmlenc::PrivateKeyDecryptor::new(key))) + } + /// Build a resolver from this inventory and one immutable key-store snapshot. + /// Trust anchors are copied once, not on each candidate lookup. + #[must_use] + pub fn verification_resolver(&self) -> InventoryVerificationResolver<'_> { + InventoryVerificationResolver { inventory: self } + } + /// Register raw symmetric bytes under a unique name. + pub fn add_symmetric( + &mut self, + name: String, + kind: SymmetricKeyKind, + bytes: Vec, + usages: KeyUsages, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.check_new_name(&name, resources)?; + if bytes.is_empty() + || bytes.len() > resources.max_external_resource_bytes + || (kind == SymmetricKeyKind::Aes && !matches!(bytes.len(), 16 | 24 | 32)) + { + return Err(KeyStoreError::Selection("invalid symmetric key length")); + } + let permitted = match kind { + SymmetricKeyKind::Hmac => KeyUsages::SIGN.union(KeyUsages::VERIFY), + SymmetricKeyKind::Aes => KeyUsages::ENCRYPT.union(KeyUsages::DECRYPT), + SymmetricKeyKind::Des => { + return Err(KeyStoreError::Selection("DES encryption is unsupported")); + } + }; + if usages.0 == 0 || usages.0 & !permitted.0 != 0 { + return Err(KeyStoreError::Selection( + "symmetric key usage is incompatible", + )); + } + self.reserve_material(named_material_length(&name, bytes.len(), 1)?, resources)?; + self.symmetric_keys.push(StoredSymmetricKey { + name, + kind, + bytes: Zeroizing::new(bytes), + usages, + }); + self.entry_count += 1; + Ok(()) + } + + /// Register DER SubjectPublicKeyInfo or a complete X.509 certificate. + /// A certificate is a lookup candidate, never an implicit trust anchor. + pub fn add_public_der( + &mut self, + name: String, + der: Vec, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.add_public_der_inner(name, der, None, resources) + } + + /// Register public DER with explicit verification/encryption permissions. + pub fn add_public_der_with_usages( + &mut self, + name: String, + der: Vec, + usages: KeyUsages, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.add_public_der_inner(name, der, Some(usages), resources) + } + + fn add_public_der_inner( + &mut self, + name: String, + der: Vec, + usages: Option, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.check_new_name(&name, resources)?; + let permitted = KeyUsages::VERIFY.union(KeyUsages::ENCRYPT); + if usages.is_some_and(|usages| usages.0 == 0 || usages.0 & !permitted.0 != 0) { + return Err(KeyStoreError::Selection("public key usage is incompatible")); + } + if der.len() > resources.max_external_resource_bytes { + return Err(KeyStoreError::Selection( + "public key exceeds resource limit", + )); + } + self.check_material_capacity(named_material_length(&name, der.len(), 2)?, resources)?; + let material_len = der.len(); + let mut key_info = KeyInfo::default(); + key_info.sources.push(KeyInfoSource::KeyName(name.clone())); + let is_rsa = if let Ok((rest, spki)) = + x509_parser::x509::SubjectPublicKeyInfo::from_der(&der) + && rest.is_empty() + && spki.raw == der + { + let is_rsa = crate::xmldsig::keys::supported_parsed_spki_is_rsa(&spki, &der) + .map_err(|_| KeyStoreError::Selection("unsupported public key algorithm"))?; + key_info + .sources + .push(KeyInfoSource::DerEncodedKeyValue(der)); + is_rsa + } else { + let (rest, certificate) = X509Certificate::from_der(&der) + .map_err(|_| KeyStoreError::Selection("invalid public key or X.509 certificate"))?; + if !rest.is_empty() { + return Err(KeyStoreError::Selection("invalid X.509 certificate")); + } + let parsed = crate::xmldsig::parse::parse_x509_certificate(&der) + .map_err(|_| KeyStoreError::Selection("unsupported X.509 certificate key"))?; + let is_rsa = crate::xmldsig::keys::supported_parsed_spki_is_rsa( + certificate.public_key(), + certificate.public_key().raw, + ) + .map_err(|_| KeyStoreError::Selection("unsupported X.509 certificate key"))?; + key_info.sources.push(KeyInfoSource::X509Data(X509DataInfo { + certificates: vec![der], + parsed_certificates: vec![parsed], + certificate_chain: vec![0], + ..X509DataInfo::default() + })); + is_rsa + }; + let usages = usages.unwrap_or(if is_rsa { permitted } else { KeyUsages::VERIFY }); + if usages.allows(KeyUsage::Encrypt) && !is_rsa { + return Err(KeyStoreError::Selection( + "only RSA public keys can be used for encryption", + )); + } + self.reserve_material(named_material_length(&name, material_len, 2)?, resources)?; + self.public_keys.push(StoredPublicKey { + name, + key_info, + usages, + }); + self.entry_count += 1; + Ok(()) + } + + /// Import one PEM-encoded public key. RFC 7468 labels select SPKI or + /// PKCS#1; extra text and multiple armor blocks are rejected. + pub fn add_public_pem( + &mut self, + name: String, + bytes: &[u8], + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.add_public_pem_inner(name, bytes, None, resources) + } + + /// Import one PEM public key with explicit verification/encryption permissions. + pub fn add_public_pem_with_usages( + &mut self, + name: String, + bytes: &[u8], + usages: KeyUsages, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.add_public_pem_inner(name, bytes, Some(usages), resources) + } + + fn add_public_pem_inner( + &mut self, + name: String, + bytes: &[u8], + usages: Option, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + ensure_resource_policy(resources)?; + self.check_new_name(&name, resources)?; + self.check_material_capacity(named_material_length(&name, bytes.len(), 2)?, resources)?; + let block = single_pem_block(bytes, resources.max_external_resource_bytes)?; + let der = match block.tag() { + "PUBLIC KEY" => block.into_contents(), + "RSA PUBLIC KEY" => { + let components = rsa::pkcs1::RsaPublicKey::from_der(block.contents()) + .map_err(|_| KeyStoreError::Selection("invalid RSA public key"))?; + crate::xmldsig::keys::bounded_rsa_public_components( + components.modulus.as_bytes(), + components.public_exponent.as_bytes(), + ) + .map_err(|_| KeyStoreError::Selection("RSA public key exceeds safety limit"))?; + RsaPublicKey::from_pkcs1_der(block.contents()) + .ok() + .and_then(|key| key.to_public_key_der().ok()) + .map(|der| der.as_bytes().to_vec()) + .ok_or(KeyStoreError::Selection("invalid RSA public key"))? + } + _ => return Err(KeyStoreError::Selection("unsupported public PEM label")), + }; + let previous_total = self.material_bytes; + let charged_total = self.check_material_capacity( + named_material_length(&name, bytes.len().max(der.len()), 2)?, + resources, + )?; + self.add_public_der_inner(name, der, usages, resources)?; + debug_assert!(self.material_bytes >= previous_total); + self.material_bytes = charged_total; + Ok(()) + } + + /// Import a DER private key as PKCS#8 (plain or encrypted) or RSA PKCS#1. + /// Passwords are consulted only for a structurally encrypted container; + /// a wrong password never retries a plaintext decoder. + pub fn add_private_der( + &mut self, + name: String, + bytes: &[u8], + password: Option<&[u8]>, + usages: KeyUsages, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.check_new_name(&name, resources)?; + if bytes.len() > resources.max_external_resource_bytes { + return Err(KeyStoreError::Selection( + "private key exceeds resource limit", + )); + } + self.check_material_capacity(named_material_length(&name, bytes.len(), 1)?, resources)?; + let permitted = KeyUsages::SIGN.union(KeyUsages::DECRYPT); + if usages.0 == 0 || usages.0 & !permitted.0 != 0 { + return Err(KeyStoreError::Selection( + "private key usage is incompatible", + )); + } + let der = if PrivateKeyInfoRef::try_from(bytes).is_ok() { + Zeroizing::new(bytes.to_vec()) + } else if let Ok(encrypted) = EncryptedPrivateKeyInfoRef::try_from(bytes) { + enforce_pkcs8_kdf_policy(&encrypted, resources)?; + let password = password.ok_or(KeyStoreError::ProtectedContainer)?; + let plain = encrypted + .decrypt(password) + .map_err(|_| KeyStoreError::ProtectedContainer)?; + Zeroizing::new(plain.as_bytes().to_vec()) + } else { + preflight_rsa_pkcs1_components(bytes)?; + let rsa = RsaPrivateKey::from_pkcs1_der(bytes) + .map_err(|_| KeyStoreError::Selection("unsupported private key DER"))?; + let normalized = rsa + .to_pkcs8_der() + .map_err(|_| KeyStoreError::Selection("invalid RSA private key"))?; + Zeroizing::new(normalized.as_bytes().to_vec()) + }; + if der.len() > resources.max_external_resource_bytes { + return Err(KeyStoreError::Selection( + "private key exceeds resource limit", + )); + } + private_key_spki(&der)?; + if usages.allows(KeyUsage::Decrypt) && RsaPrivateKey::from_pkcs8_der(&der).is_err() { + return Err(KeyStoreError::Selection( + "only RSA private keys can be used for decryption", + )); + } + self.reserve_material( + named_material_length(&name, bytes.len().max(der.len()), 1)?, + resources, + )?; + self.private_keys.push(StoredPrivateKey { + name, + pkcs8_der: der, + usages, + certificate_chain: Vec::new(), + has_matching_leaf: false, + }); + self.entry_count += 1; + Ok(()) + } + + /// Import private DER using a caller-owned password callback only when + /// the input is an encrypted PKCS#8 container. + pub fn add_private_der_with_password_callback( + &mut self, + name: String, + bytes: &[u8], + password: F, + usages: KeyUsages, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> + where + F: FnOnce() -> Option>>, + { + self.check_new_name(&name, resources)?; + if bytes.len() > resources.max_external_resource_bytes { + return Err(KeyStoreError::Selection( + "private key exceeds resource limit", + )); + } + self.check_material_capacity(named_material_length(&name, bytes.len(), 1)?, resources)?; + let secret = if let Ok(encrypted) = EncryptedPrivateKeyInfoRef::try_from(bytes) { + enforce_pkcs8_kdf_policy(&encrypted, resources)?; + Some(password().ok_or(KeyStoreError::ProtectedContainer)?) + } else { + None + }; + self.add_private_der( + name, + bytes, + secret.as_deref().map(Vec::as_slice), + usages, + resources, + ) + } + + /// Import one PEM private key, including encrypted PKCS#8. Traditional + /// OpenSSL PEM encryption is handled at the CLI compatibility boundary. + pub fn add_private_pem( + &mut self, + name: String, + bytes: &[u8], + password: Option<&[u8]>, + usages: KeyUsages, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + ensure_resource_policy(resources)?; + self.check_new_name(&name, resources)?; + self.check_material_capacity(named_material_length(&name, bytes.len(), 1)?, resources)?; + let block = single_pem_block(bytes, resources.max_external_resource_bytes)?; + match block.tag() { + "PRIVATE KEY" | "ENCRYPTED PRIVATE KEY" | "RSA PRIVATE KEY" => { + let der = Zeroizing::new(block.into_contents()); + let previous_total = self.material_bytes; + let name_len = name.len(); + self.add_private_der(name, &der, password, usages, resources)?; + let retained_len = self.material_bytes - previous_total; + self.material_bytes = + previous_total + name_len + bytes.len().max(retained_len - name_len); + Ok(()) + } + _ => Err(KeyStoreError::Selection("unsupported private PEM label")), + } + } + + /// Import a bounded PKCS#12 bundle from caller-owned bytes. The key may + /// sign; RSA keys may also decrypt. A bundle with more than one private + /// key is rejected rather than assigning names from iteration order. + pub fn add_pkcs12( + &mut self, + name: String, + bytes: &[u8], + password: &str, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.add_pkcs12_inner( + name, + bytes, + password, + KeyUsages::SIGN.union(KeyUsages::DECRYPT), + resources, + true, + ) + } + + /// Import PKCS#12 using a caller-owned password callback. Its result is + /// zeroized after decoding and callback failure exposes no diagnostic. + pub fn add_pkcs12_with_password_callback( + &mut self, + name: String, + bytes: &[u8], + password: F, + usages: KeyUsages, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> + where + F: FnOnce() -> Option>, + { + let limits = self.pkcs12_import_limits(&name, bytes, usages, resources)?; + let prepared = pkcs12_import::prepare(bytes, &limits)?; + let secret = password().ok_or(KeyStoreError::ProtectedContainer)?; + let contents = prepared.decrypt(&secret)?; + self.add_pkcs12_contents(name, bytes.len(), contents, usages, resources, false) + } + + /// Import a PKCS#12 bundle with explicit signing/decryption permissions. + pub fn add_pkcs12_with_usages( + &mut self, + name: String, + bytes: &[u8], + password: &str, + usages: KeyUsages, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.add_pkcs12_inner(name, bytes, password, usages, resources, false) + } + + fn add_pkcs12_inner( + &mut self, + name: String, + bytes: &[u8], + password: &str, + usages: KeyUsages, + resources: &ResourcePolicy, + auto_decrypt: bool, + ) -> Result<(), KeyStoreError> { + let limits = self.pkcs12_import_limits(&name, bytes, usages, resources)?; + let contents = pkcs12_import::prepare(bytes, &limits)?.decrypt(password)?; + self.add_pkcs12_contents(name, bytes.len(), contents, usages, resources, auto_decrypt) + } + + fn add_pkcs12_contents( + &mut self, + name: String, + encoded_len: usize, + mut contents: pkcs12_import::Contents, + mut usages: KeyUsages, + resources: &ResourcePolicy, + auto_decrypt: bool, + ) -> Result<(), KeyStoreError> { + if contents.private_keys.len() != 1 { + return Err(KeyStoreError::Selection( + "PKCS#12 bundle must contain exactly one private key", + )); + } + let private_key = contents + .private_keys + .pop() + .ok_or(KeyStoreError::ProtectedContainer)?; + let mut certificates = contents.certificates; + let spki = private_key_spki(private_key.as_ref())?; + if usages.allows(KeyUsage::Decrypt) + && RsaPrivateKey::from_pkcs8_der(private_key.as_ref()).is_err() + { + if auto_decrypt { + usages = KeyUsages::SIGN; + } else { + return Err(KeyStoreError::Selection( + "only RSA private keys can be used for decryption", + )); + } + } + let mut matching_leaf = None; + for certificate in &certificates { + let (rest, parsed) = X509Certificate::from_der(certificate) + .map_err(|_| KeyStoreError::Selection("invalid certificate in PKCS#12"))?; + if !rest.is_empty() { + return Err(KeyStoreError::Selection("invalid certificate in PKCS#12")); + } + if parsed.public_key().raw == spki.as_slice() { + if matching_leaf.is_some_and(|leaf: &[u8]| leaf != certificate.as_slice()) { + return Err(KeyStoreError::Selection( + "ambiguous certificate for PKCS#12 private key", + )); + } + // RFC 7292 section 4.2 permits repeated certificate bags. + // Identical DER is the same leaf, not a second candidate. + matching_leaf = Some(certificate.as_slice()); + } + } + // PKCS#12 may carry unrelated CA certificates. They remain lookup + // material; only an actual SPKI match is promoted to the leaf slot. + let has_matching_leaf = matching_leaf.is_some(); + if let Some(leaf) = matching_leaf { + let position = certificates + .iter() + .position(|candidate| candidate == leaf) + .ok_or(KeyStoreError::ProtectedContainer)?; + certificates.swap(0, position); + let mut index = 1; + while index < certificates.len() { + if certificates[index] == certificates[0] { + certificates.remove(index); + } else { + index += 1; + } + } + } + let decoded_bytes = certificates + .iter() + .try_fold(private_key.len(), |total, cert| { + total + .checked_add(cert.len()) + .ok_or(KeyStoreError::Selection("key material size overflow")) + })?; + let retained_candidates = 1_usize + .checked_add(certificates.len()) + .ok_or(KeyStoreError::Selection("key candidate count overflow"))?; + let remaining_candidates = resources.max_key_candidates - self.entry_count; + if retained_candidates > remaining_candidates { + return Err(crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::KEY_CANDIDATES, + maximum: remaining_candidates, + } + .into()); + } + self.reserve_material( + named_material_length(&name, decoded_bytes.max(encoded_len), 1)?, + resources, + )?; + self.private_keys.push(StoredPrivateKey { + name, + pkcs8_der: private_key, + usages, + certificate_chain: certificates, + has_matching_leaf, + }); + self.entry_count += retained_candidates; + Ok(()) + } + + fn pkcs12_import_limits( + &self, + name: &str, + bytes: &[u8], + usages: KeyUsages, + resources: &ResourcePolicy, + ) -> Result { + self.check_new_name(name, resources)?; + let permitted = KeyUsages::SIGN.union(KeyUsages::DECRYPT); + if usages.0 == 0 || usages.0 & !permitted.0 != 0 { + return Err(KeyStoreError::Selection( + "private key usage is incompatible", + )); + } + if bytes.len() > resources.max_external_resource_bytes { + return Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_bytes, + }, + )); + } + self.check_material_capacity(named_material_length(name, bytes.len(), 1)?, resources)?; + let remaining_candidates = resources.max_key_candidates - self.entry_count; + Ok(Pkcs12Limits { + resources: resources.clone(), + candidates: remaining_candidates, + memory_available: resources.max_external_resource_total_bytes + - self.material_bytes + - name.len(), + }) + } + + fn check_new_name(&self, name: &str, resources: &ResourcePolicy) -> Result<(), KeyStoreError> { + ensure_resource_policy(resources)?; + if name.is_empty() || self.entry_count >= resources.max_key_candidates { + return Err(KeyStoreError::Selection( + "name or key candidate limit is invalid", + )); + } + if name.len() > resources.max_external_resource_bytes { + return Err(KeyStoreError::Selection("key name exceeds resource limit")); + } + self.check_material_capacity(name.len(), resources)?; + if self.symmetric_keys.iter().any(|key| key.name == name) + || self.public_keys.iter().any(|key| key.name == name) + || self.private_keys.iter().any(|key| key.name == name) + { + return Err(KeyStoreError::Selection("duplicate key name")); + } + Ok(()) + } + + fn check_material_capacity( + &self, + length: usize, + resources: &ResourcePolicy, + ) -> Result { + let total = self + .material_bytes + .checked_add(length) + .ok_or(KeyStoreError::Selection("key material size overflow"))?; + if total > resources.max_external_resource_total_bytes { + return Err(KeyStoreError::Selection( + "key material total exceeds resource limit", + )); + } + Ok(total) + } + + fn reserve_material( + &mut self, + length: usize, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.material_bytes = self.check_material_capacity(length, resources)?; + Ok(()) + } + + /// Import a DER certificate as an untrusted lookup candidate or an + /// explicitly caller-trusted anchor. Parsing alone never grants trust. + pub fn add_certificate_der( + &mut self, + der: Vec, + trusted_anchor: bool, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + ensure_resource_policy(resources)?; + if der.len() > resources.max_external_resource_bytes { + return Err(KeyStoreError::Selection( + "certificate exceeds resource limit", + )); + } + let (rest, _) = X509Certificate::from_der(&der) + .map_err(|_| KeyStoreError::Selection("invalid X.509 certificate"))?; + if !rest.is_empty() { + return Err(KeyStoreError::Selection("invalid X.509 certificate")); + } + if self.entry_count >= resources.max_key_candidates { + return Err(KeyStoreError::Selection("key candidate limit exceeded")); + } + self.reserve_material(der.len(), resources)?; + if trusted_anchor { + self.trusted_certificates.push(der); + } else { + self.lookup_certificates.push(der); + } + self.entry_count += 1; + Ok(()) + } + + /// Import a DER CRL for policy-controlled revocation checks. + pub fn add_crl_der( + &mut self, + der: Vec, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + ensure_resource_policy(resources)?; + if der.len() > resources.max_external_resource_bytes { + return Err(KeyStoreError::Selection("CRL exceeds resource limit")); + } + let (rest, _) = x509_parser::revocation_list::CertificateRevocationList::from_der(&der) + .map_err(|_| KeyStoreError::Selection("invalid X.509 CRL"))?; + if !rest.is_empty() { + return Err(KeyStoreError::Selection("invalid X.509 CRL")); + } + if self.entry_count >= resources.max_key_candidates { + return Err(KeyStoreError::Selection("key candidate limit exceeded")); + } + self.reserve_material(der.len(), resources)?; + self.crls.push(der); + self.entry_count += 1; + Ok(()) + } + /// Import caller-owned XML bytes under the operation's XML and resource snapshot. + pub fn from_xml_bytes( + bytes: &[u8], + policy: &P, + backend: XmlBackend, + ) -> Result { + let resources = policy.resource_policy(); + ensure_resource_policy(resources)?; + if bytes.len() > resources.max_external_resource_bytes + || bytes.len() > resources.max_external_resource_total_bytes + { + return Err(KeyStoreError::Selection( + "XML key store exceeds resource limit", + )); + } + let settings = DocumentParseSettings::from_policy(policy.xml_input_policy(), resources) + .with_backend(backend); + let budget = XmlParseWorkBudget::from_resources(resources); + let text = crate::document::decode_xml_with_budget( + bytes, + resources + .max_xml_document_bytes + .min(resources.max_external_resource_bytes), + Some(&budget), + ) + .map_err(|error| KeyStoreError::Invalid(error.to_string()))?; + let document = parse_borrowed_with_settings_and_budget(&text, settings, Some(&budget)) + .map_err(|error| KeyStoreError::Invalid(error.to_string()))?; + let root = document.root_element(); + if !root.has_tag_name((XMLSEC_NS, "Keys")) { + return Err(KeyStoreError::Invalid("expected xmlsec Keys root".into())); + } + let mut names = HashSet::new(); + let mut store = Self::default(); + let mut entry_count = 0_usize; + for info in root.children().filter(|child| child.is_element()) { + if !info.has_tag_name((XMLDSIG_NS, "KeyInfo")) { + return Err(KeyStoreError::Invalid("unexpected child of Keys".into())); + } + if entry_count >= resources.max_key_candidates { + return Err(KeyStoreError::Invalid( + "key candidate limit exceeded".into(), + )); + } + entry_count += 1; + let mut name = None; + let mut value = None; + for child in info.children().filter(|child| child.is_element()) { + if child.has_tag_name((XMLDSIG_NS, "KeyName")) { + if name.is_some() { + return Err(KeyStoreError::Invalid("duplicate KeyName".into())); + } + let text = element_text(child)?; + if text.is_empty() { + return Err(KeyStoreError::Invalid("empty KeyName".into())); + } + name = Some(text); + } else if child.has_tag_name((XMLDSIG_NS, "KeyValue")) { + if value.is_some() { + return Err(KeyStoreError::Invalid("duplicate KeyValue".into())); + } + let mut values = child.children().filter(|node| node.is_element()); + let key = values.next().ok_or_else(|| { + KeyStoreError::Invalid("KeyValue has no key material".into()) + })?; + if values.next().is_some() { + return Err(KeyStoreError::Invalid("ambiguous KeyValue".into())); + } + let material = if key.has_tag_name((XMLSEC_NS, "HMACKeyValue")) { + Some(SymmetricKeyKind::Hmac) + } else if key.has_tag_name((XMLSEC_NS, "AESKeyValue")) { + Some(SymmetricKeyKind::Aes) + } else if key.has_tag_name((XMLSEC_NS, "DESKeyValue")) { + Some(SymmetricKeyKind::Des) + } else { + None + }; + value = Some(if let Some(kind) = material { + ParsedMaterial::Symmetric(kind, Zeroizing::new(decode_xml_base64(key)?)) + } else if key.has_tag_name((XMLDSIG_NS, "DSAKeyValue")) { + let (public, private) = parse_xmlsec_dsa_key_value(key)?; + ParsedMaterial::Dsa(public, private) + } else if key.has_tag_name((XMLDSIG_NS, "RSAKeyValue")) + // XMLDSig 1.1 section 4.5.2.3 places ECKeyValue in dsig11: + // https://www.w3.org/TR/2013/REC-xmldsig-core1-20130411/#sec-ECKeyValue + || key.has_tag_name((XMLDSIG11_NS, "ECKeyValue")) + { + ParsedMaterial::Public(None) + } else { + ParsedMaterial::Unsupported + }); + } else { + return Err(KeyStoreError::Invalid("unsupported KeyInfo child".into())); + } + } + let name = name.ok_or_else(|| KeyStoreError::Invalid("missing KeyName".into()))?; + let material = + value.ok_or_else(|| KeyStoreError::Invalid("missing KeyValue".into()))?; + if !names.insert(name.clone()) { + return Err(KeyStoreError::Invalid("duplicate key name".into())); + } + match material { + ParsedMaterial::Symmetric(kind, bytes) => { + if bytes.is_empty() { + return Err(KeyStoreError::Invalid("empty symmetric key".into())); + } + if kind == SymmetricKeyKind::Aes && !matches!(bytes.len(), 16 | 24 | 32) { + return Err(KeyStoreError::Invalid("invalid AES key length".into())); + } + let usages = match kind { + SymmetricKeyKind::Hmac => KeyUsages::SIGN.union(KeyUsages::VERIFY), + SymmetricKeyKind::Aes => KeyUsages::ENCRYPT.union(KeyUsages::DECRYPT), + SymmetricKeyKind::Des => { + return Err(KeyStoreError::Selection("DES encryption is unsupported")); + } + }; + store.symmetric_keys.push(StoredSymmetricKey { + name, + kind, + bytes, + usages, + }); + } + ParsedMaterial::Public(manual_value) => { + let key_info = if let Some(value) = manual_value { + let mut key_info = KeyInfo::default(); + key_info.sources.push(KeyInfoSource::KeyName(name.clone())); + key_info.sources.push(KeyInfoSource::KeyValue(value)); + key_info + } else { + parse_key_info(info) + .map_err(|error| KeyStoreError::Invalid(error.to_string()))? + }; + let is_rsa = key_info + .sources + .iter() + .find_map(|source| match source { + KeyInfoSource::KeyValue(value) => Some(value), + _ => None, + }) + .ok_or_else(|| KeyStoreError::Invalid("invalid public KeyValue".into()))?; + let is_rsa = crate::xmldsig::keys::supported_key_value_is_rsa(is_rsa) + .map_err(|_| KeyStoreError::Invalid("invalid public KeyValue".into()))?; + let usages = if is_rsa { + KeyUsages::VERIFY.union(KeyUsages::ENCRYPT) + } else { + KeyUsages::VERIFY + }; + store.public_keys.push(StoredPublicKey { + name, + key_info, + usages, + }); + } + ParsedMaterial::Dsa(public, private) => { + // This inventory has no external DSA parameter inheritance; + // its stored public tuple must be independently resolvable. + crate::xmldsig::keys::supported_key_value_is_rsa(&public) + .map_err(|_| KeyStoreError::Invalid("invalid public DSAKeyValue".into()))?; + let mut key_info = KeyInfo::default(); + key_info.sources.push(KeyInfoSource::KeyName(name.clone())); + key_info.sources.push(KeyInfoSource::KeyValue(public)); + if let Some(pkcs8_der) = private { + store.private_keys.push(StoredPrivateKey { + name: name.clone(), + pkcs8_der, + usages: KeyUsages::SIGN, + certificate_chain: Vec::new(), + has_matching_leaf: false, + }); + } + store.public_keys.push(StoredPublicKey { + name, + key_info, + usages: KeyUsages::VERIFY, + }); + } + ParsedMaterial::Unsupported => {} + } + } + store.entry_count = entry_count; + // Decoding can retain both public components and a derived private key. + // Keep the input charge too, so compact XML never lowers the import budget. + store.material_bytes = bytes.len().max(store.retained_material_bytes()?); + if store.material_bytes > resources.max_external_resource_total_bytes { + return Err(KeyStoreError::Selection( + "key material total exceeds resource limit", + )); + } + Ok(store) + } +} + +fn check_operation_material_size( + length: usize, + resources: &ResourcePolicy, +) -> Result<(), KeyStoreError> { + if length > resources.max_external_resource_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_bytes, + actual: length, + } + .into()); + } + if length > resources.max_external_resource_total_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_total_bytes, + actual: length, + } + .into()); + } + Ok(()) +} + +#[cfg(feature = "xmlenc")] +fn check_selected_material_size( + length: usize, + resources: &ResourcePolicy, +) -> Result<(), KeyStoreError> { + check_operation_material_size(length, resources) +} + +#[cfg(feature = "xmlenc")] +fn check_encryption_material_size( + length: usize, + resources: &ResourcePolicy, +) -> Result<(), KeyStoreError> { + check_operation_material_size(length, resources) +} + +#[cfg(feature = "xmlenc")] +fn rsa_recipient_preflight( + modulus: &[u8], + exponent: &[u8], + policy: &crate::policy::EncryptionPolicy, +) -> Result<(), KeyStoreError> { + let combined = modulus + .len() + .checked_add(exponent.len()) + .ok_or(KeyStoreError::Selection("encryption key size overflow"))?; + check_encryption_material_size(combined, &policy.resources)?; + policy + .rsa_keys + .validate_components("encryption", modulus, exponent)?; + Ok(()) +} + +#[cfg(feature = "xmlenc")] +fn rsa_recipient_from_components( + modulus: &[u8], + exponent: &[u8], + policy: &crate::policy::EncryptionPolicy, +) -> Result { + rsa_recipient_preflight(modulus, exponent, policy)?; + let first_nonzero = modulus + .iter() + .position(|byte| *byte != 0) + .ok_or(KeyStoreError::Selection("invalid RSA encryption key"))?; + RsaPublicKey::new( + BoxedUint::from_be_slice_vartime(&modulus[first_nonzero..]), + BoxedUint::from_be_slice_vartime(exponent), + ) + .map_err(|_| KeyStoreError::Selection("invalid RSA encryption key")) +} + +fn signing_policy_error(error: crate::xmldsig::SigningError) -> KeyStoreError { + match error { + crate::xmldsig::SigningError::Policy(violation) => violation.into(), + _ => KeyStoreError::Selection("signing key violates operation policy"), + } +} + +fn ensure_resource_policy(resources: &ResourcePolicy) -> Result<(), KeyStoreError> { + resources.validate().map_err(Into::into) +} + +fn find_named_entry<'a, T>( + entries: &'a [T], + name: &str, + resources: &ResourcePolicy, + visited: &mut usize, + entry_name: impl Fn(&T) -> &str, +) -> Result, KeyStoreError> { + for entry in entries { + let next = visited + .checked_add(1) + .ok_or(KeyStoreError::Selection("key candidate count overflow"))?; + resources.validate_key_candidates(next)?; + *visited = next; + if entry_name(entry) == name { + return Ok(Some(entry)); + } + } + Ok(None) +} + +fn named_material_length( + name: &str, + payload: usize, + retained_names: usize, +) -> Result { + name.len() + .checked_mul(retained_names) + .and_then(|names| names.checked_add(payload)) + .ok_or(KeyStoreError::Selection("key material size overflow")) +} + +fn private_key_spki(der: &[u8]) -> Result, KeyStoreError> { + let info = PrivateKeyInfoRef::try_from(der) + .map_err(|_| KeyStoreError::Selection("unsupported PKCS#12 private key"))?; + if info.algorithm.oid == rsa::pkcs1::ALGORITHM_OID { + preflight_rsa_pkcs1_components(info.private_key.as_bytes())?; + } else if info.algorithm.oid == dsa::OID { + preflight_dsa_pkcs8_components(&info)?; + } + macro_rules! try_key { + ($key:ty) => { + if let Ok(key) = <$key>::from_pkcs8_der(der) { + return key + .public_key_info() + .ok() + .and_then(|info| info.spki_der().map(ToOwned::to_owned)) + .ok_or(KeyStoreError::Selection("private key has no public key")); + } + }; + } + try_key!(RsaSigningKey); + try_key!(DsaSigningKey); + try_key!(EcdsaP256SigningKey); + try_key!(EcdsaP384SigningKey); + try_key!(EcdsaP521SigningKey); + Err(KeyStoreError::Selection("unsupported PKCS#12 private key")) +} + +fn preflight_rsa_pkcs1_components(der: &[u8]) -> Result<(), KeyStoreError> { + let key = rsa::pkcs1::RsaPrivateKey::from_der(der) + .map_err(|_| KeyStoreError::Selection("invalid RSA private key"))?; + let modulus = key.modulus.as_bytes(); + let maximum = crate::hard_limits::RSA_MODULUS_BIT_CEILING; + if modulus.len() > maximum.div_ceil(8) + || modulus + .first() + .is_some_and(|first| modulus.len() * 8 - first.leading_zeros() as usize > maximum) + { + return Err(KeyStoreError::Selection("RSA modulus exceeds safety limit")); + } + if [ + key.public_exponent, + key.private_exponent, + key.prime1, + key.prime2, + key.exponent1, + key.exponent2, + key.coefficient, + ] + .into_iter() + .any(|component| component.as_bytes().len() > maximum.div_ceil(8)) + || key.other_prime_infos.as_ref().is_some_and(|infos| { + infos.iter().any(|info| { + [info.prime, info.exponent, info.coefficient] + .into_iter() + .any(|component| component.as_bytes().len() > maximum.div_ceil(8)) + }) + }) + { + return Err(KeyStoreError::Selection( + "RSA component exceeds safety limit", + )); + } + Ok(()) +} + +#[derive(der::Sequence)] +struct BorrowedDsaParameters<'a> { + p: der::asn1::UintRef<'a>, + q: der::asn1::UintRef<'a>, + g: der::asn1::UintRef<'a>, +} + +fn preflight_dsa_pkcs8_components(info: &PrivateKeyInfoRef<'_>) -> Result<(), KeyStoreError> { + let parameters = info + .algorithm + .parameters + .as_ref() + .ok_or(KeyStoreError::Selection("missing DSA parameters"))? + .decode_as::>() + .map_err(|_| KeyStoreError::Selection("invalid DSA parameters"))?; + let x = der::asn1::UintRef::from_der(info.private_key.as_bytes()) + .map_err(|_| KeyStoreError::Selection("invalid DSA private exponent"))?; + if [parameters.p, parameters.q, parameters.g, x] + .into_iter() + .any(|component| { + component.as_bytes().len() > crate::hard_limits::DSA_KEY_COMPONENT_BYTE_CEILING + }) + { + return Err(KeyStoreError::Selection( + "DSA component exceeds safety limit", + )); + } + Ok(()) +} + +fn single_pem_block(bytes: &[u8], maximum: usize) -> Result { + if bytes.len() > maximum { + return Err(KeyStoreError::Selection("PEM key exceeds resource limit")); + } + let text = std::str::from_utf8(bytes) + .map_err(|_| KeyStoreError::Selection("PEM key is not ASCII text"))? + .trim_matches(|character: char| character.is_ascii_whitespace()); + let block = pem::parse(text).map_err(|_| KeyStoreError::Selection("invalid PEM key"))?; + let begin = format!("-----BEGIN {}-----", block.tag()); + let end = format!("-----END {}-----", block.tag()); + if !text.starts_with(&begin) + || !text.ends_with(&end) + || text.matches(&begin).count() != 1 + || text.matches(&end).count() != 1 + { + return Err(KeyStoreError::Selection( + "PEM must contain one complete block", + )); + } + Ok(block) +} + +fn enforce_pkcs8_kdf_policy( + encrypted: &EncryptedPrivateKeyInfoRef<'_>, + resources: &ResourcePolicy, +) -> Result<(), KeyStoreError> { + use pkcs8::pkcs5::{EncryptionScheme, pbes2::Kdf}; + // RFC 8018 §6.2 leaves KDF iteration policy to the application. Reject + // excessive work before decrypting attacker-supplied containers. + // https://www.rfc-editor.org/rfc/rfc8018#section-6.2 + let EncryptionScheme::Pbes2(params) = &encrypted.encryption_algorithm else { + return Err(KeyStoreError::ProtectedContainer); + }; + match ¶ms.kdf { + Kdf::Pbkdf2(kdf) => { + if kdf.iteration_count == 0 { + return Err(KeyStoreError::ProtectedContainer); + } + if u64::from(kdf.iteration_count) > resources.max_key_import_kdf_work as u64 { + return Err(kdf_policy_violation( + crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + resources.max_key_import_kdf_work, + Some(u64::from(kdf.iteration_count)), + )); + } + } + Kdf::Scrypt(kdf) => { + enforce_scrypt_kdf_limits( + kdf.cost_parameter, + u64::from(kdf.block_size), + u64::from(kdf.parallelization), + resources, + )?; + } + _ => return Err(KeyStoreError::ProtectedContainer), + } + Ok(()) +} + +fn enforce_scrypt_kdf_limits( + n: u64, + r: u64, + p: u64, + resources: &ResourcePolicy, +) -> Result<(), KeyStoreError> { + if n == 0 || r == 0 || p == 0 { + return Err(KeyStoreError::ProtectedContainer); + } + let work = n.checked_mul(r).and_then(|value| value.checked_mul(p)); + if work.is_none_or(|value| value > resources.max_key_import_kdf_work as u64) { + return Err(kdf_policy_violation( + crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + resources.max_key_import_kdf_work, + work, + )); + } + // RustCrypto scrypt retains B[p*r] plus V[N*r] and T[r] per parallel + // worker when its `parallel` feature is unified in a downstream build. + let memory = n + .checked_add(2) + .and_then(|blocks| blocks.checked_mul(p)) + .and_then(|blocks| blocks.checked_mul(r)) + .and_then(|blocks| blocks.checked_mul(128)); + if memory.is_none_or(|value| value > resources.max_key_import_kdf_memory_bytes as u64) { + return Err(kdf_policy_violation( + crate::policy::resource_name::KEY_IMPORT_KDF_MEMORY, + resources.max_key_import_kdf_memory_bytes, + memory, + )); + } + Ok(()) +} + +fn kdf_policy_violation( + resource: &'static str, + maximum: usize, + actual: Option, +) -> KeyStoreError { + KeyStoreError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource, + maximum, + actual: actual + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(usize::MAX), + }) +} + +fn element_text(node: Node<'_, '_>) -> Result { + let mut text = String::new(); + for child in node.children() { + if child.is_element() { + return Err(KeyStoreError::Invalid("unexpected nested element".into())); + } + if child.is_text() { + text.push_str(child.text().unwrap_or_default()); + } + } + Ok(text) +} + +fn decode_xml_base64(node: Node<'_, '_>) -> Result, KeyStoreError> { + let encoded = element_text(node)?; + let normalized = encoded + .bytes() + .filter(|byte| !matches!(byte, b' ' | b'\t' | b'\r' | b'\n')) + .collect::>(); + base64::engine::general_purpose::STANDARD + .decode(normalized) + .map_err(|_| KeyStoreError::Invalid("invalid key base64".into())) +} + +fn parse_xmlsec_dsa_key_value(node: Node<'_, '_>) -> Result { + let mut p = None; + let mut q = None; + let mut g = None; + let mut y = None; + let mut seed = None; + let mut counter = None; + let mut private_x = None; + let mut previous_position = None; + for child in node.children().filter(|child| child.is_element()) { + let (position, slot) = if child.has_tag_name((XMLDSIG_NS, "P")) { + (0, Some(&mut p)) + } else if child.has_tag_name((XMLDSIG_NS, "Q")) { + (1, Some(&mut q)) + } else if child.has_tag_name((XMLDSIG_NS, "G")) { + (2, Some(&mut g)) + } else if child.has_tag_name((XMLDSIG_NS, "Y")) { + (4, Some(&mut y)) + } else if child.has_tag_name((XMLSEC_NS, "X")) { + if private_x.is_some() { + return Err(KeyStoreError::Invalid("duplicate DSA X".into())); + } + // XMLDSig 1.1 §4.5.2.1 has no private X field. libxmlsec's + // keys.xml adds one before Y; only this store importer accepts it. + // https://www.w3.org/TR/xmldsig-core1/#sec-DSAKeyValue + private_x = Some(Zeroizing::new(decode_xml_base64(child)?)); + (3, None) + } else if child.has_tag_name((XMLDSIG_NS, "J")) { + (5, None) + } else if child.has_tag_name((XMLDSIG_NS, "Seed")) { + (6, Some(&mut seed)) + } else if child.has_tag_name((XMLDSIG_NS, "PgenCounter")) { + (7, Some(&mut counter)) + } else { + return Err(KeyStoreError::Invalid( + "unsupported DSAKeyValue child".into(), + )); + }; + // XMLDSig 1.1 §4.5.2.1 defines a sequence, not an unordered set. + if previous_position.is_some_and(|previous| position <= previous) { + return Err(KeyStoreError::Invalid( + "DSA parameters are out of order".into(), + )); + } + previous_position = Some(position); + if let Some(slot) = slot { + if slot.replace(decode_xml_base64(child)?).is_some() { + return Err(KeyStoreError::Invalid("duplicate DSA parameter".into())); + } + } else if position == 5 { + let _ = decode_xml_base64(child)?; + } + } + if p.is_some() != q.is_some() { + return Err(KeyStoreError::Invalid( + "DSA P and Q must occur together".into(), + )); + } + if seed.is_some() != counter.is_some() { + return Err(KeyStoreError::Invalid( + "DSA Seed and PgenCounter must occur together".into(), + )); + } + if [p.as_ref(), q.as_ref(), g.as_ref(), y.as_ref()] + .into_iter() + .flatten() + .any(|component| component.len() > crate::hard_limits::DSA_KEY_COMPONENT_BYTE_CEILING) + || private_x.as_ref().is_some_and(|component| { + component.len() > crate::hard_limits::DSA_KEY_COMPONENT_BYTE_CEILING + }) + { + return Err(KeyStoreError::Invalid( + "DSA component exceeds safety limit".into(), + )); + } + let y = y.ok_or_else(|| KeyStoreError::Invalid("DSAKeyValue requires Y".into()))?; + let private = if let Some(x) = private_x { + let (Some(p), Some(q), Some(g)) = (&p, &q, &g) else { + return Err(KeyStoreError::Invalid( + "private DSA key requires P, Q, and G".into(), + )); + }; + let components = DsaComponents::from_components( + BoxedUint::from_be_slice_vartime(p), + BoxedUint::from_be_slice_vartime(q), + BoxedUint::from_be_slice_vartime(g), + ) + .map_err(|_| KeyStoreError::Invalid("invalid DSA parameters".into()))?; + let x_value = BoxedUint::from_be_slice_vartime(&x); + let monty = BoxedMontyParams::new(components.p().clone()); + let expected_y = BoxedMontyForm::new((**components.g()).clone(), &monty) + .pow(&x_value) + .retrieve(); + if expected_y != BoxedUint::from_be_slice_vartime(&y) { + return Err(KeyStoreError::Invalid( + "DSA private and public values differ".into(), + )); + } + let public = DsaVerifyingKey::from_components(components, expected_y) + .map_err(|_| KeyStoreError::Invalid("invalid DSA public key".into()))?; + let private = NativeDsaSigningKey::from_components(public, x_value) + .map_err(|_| KeyStoreError::Invalid("invalid DSA private key".into()))?; + Some(Zeroizing::new( + private + .to_pkcs8_der() + .map_err(|_| KeyStoreError::Invalid("DSA private key encoding failed".into()))? + .as_bytes() + .to_vec(), + )) + } else { + None + }; + Ok((KeyValueInfo::Dsa { p, q, g, y }, private)) +} + +#[cfg(test)] +mod tests { + use rand_chacha::{ChaCha8Rng, rand_core::SeedableRng as _}; + use rsa::pkcs1::EncodeRsaPrivateKey as _; + + use super::*; + + fn xml_policy(resources: ResourcePolicy) -> crate::policy::VerificationPolicy { + crate::policy::VerificationPolicy { + resources, + ..crate::policy::VerificationPolicy::default() + } + } + + #[test] + fn imports_donor_pkcs12_and_rejects_wrong_password() { + // A real upstream PHAOS bundle exercises MAC, password decoding, key + // association, and certificate import rather than a synthetic ASN.1 stub. + let bytes = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_pkcs12("phaos".into(), bytes, "secret", &resources) + .expect("donor PKCS#12 should import"); + assert_eq!(inventory.private_keys.len(), 1); + assert!(!inventory.private_keys[0].certificate_chain.is_empty()); + assert!(inventory.private_keys[0].usages.allows(KeyUsage::Sign)); + + let mut wrong = KeyInventory::default(); + assert!(matches!( + wrong.add_pkcs12("phaos".into(), bytes, "wrong", &resources), + Err(KeyStoreError::ProtectedContainer) + )); + assert!(wrong.private_keys.is_empty()); + + let oversized = ResourcePolicy { + max_external_resource_bytes: bytes.len() - 1, + ..ResourcePolicy::default() + }; + assert!(matches!( + KeyInventory::default().add_pkcs12("phaos".into(), bytes, "secret", &oversized), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum, + } + )) if maximum == bytes.len() - 1 + )); + } + + #[test] + fn pkcs12_kdf_limit_is_a_typed_policy_denial() { + // A valid protected bundle that exceeds import work is not a bad password. + let bytes = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + let resources = ResourcePolicy { + max_key_import_kdf_work: 1, + ..ResourcePolicy::default() + }; + assert!(matches!( + KeyInventory::default().add_pkcs12("phaos".into(), bytes, "secret", &resources), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::KdfIterationsOutsideLimit { maximum: 1 } + )) + )); + assert!(matches!( + KeyInventory::default().add_pkcs12( + "phaos".into(), + bytes, + "wrong", + &ResourcePolicy::default(), + ), + Err(KeyStoreError::ProtectedContainer) + )); + } + + #[test] + fn pkcs12_visible_encryption_kdf_is_checked_before_password() { + // Raising an unencrypted PBES2 iteration count must deny the import + // before asking for a secret, even when MacData is within the limit. + let mut bytes = + include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12").to_vec(); + let offset = bytes + .windows(4) + .position(|v| v == [2, 2, 8, 0]) + .expect("PBKDF2 iterations"); + bytes[offset + 3] = 1; + let resources = ResourcePolicy { + max_key_import_kdf_work: 2048, + ..ResourcePolicy::default() + }; + let result = KeyInventory::default().add_pkcs12_with_password_callback( + "limited".into(), + &bytes, + || panic!("visible KDF must be checked first"), + KeyUsages::SIGN, + &resources, + ); + assert!(matches!(result, Err(KeyStoreError::Policy(_)))); + } + + #[test] + fn pkcs12_content_count_denial_is_not_a_password_error() { + // AuthenticatedSafe has two content infos; its count is public and + // must report the candidate policy rather than request a password. + let bytes = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + let resources = ResourcePolicy { + max_key_candidates: 1, + ..ResourcePolicy::default() + }; + let result = KeyInventory::default().add_pkcs12_with_password_callback( + "limited".into(), + bytes, + || panic!("container limit must be checked first"), + KeyUsages::SIGN, + &resources, + ); + assert!(matches!( + result, + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::KEY_CANDIDATES, + maximum: 1, + } + )) + )); + } + + #[test] + fn pkcs12_aggregate_kdf_work_preserves_policy_error() { + // Individual counts fit, but MAC plus PBES2 exceeds one shared budget. + let resources = ResourcePolicy { + max_key_import_kdf_work: 3000, + ..ResourcePolicy::default() + }; + let bytes = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + assert!(matches!( + KeyInventory::default().add_pkcs12("limited".into(), bytes, "secret", &resources), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + maximum: 3000, + } + )) + )); + } + + #[test] + fn pkcs12_workspace_denial_preserves_policy_error() { + // KDF workspace denial must not look like a wrong password. + let resources = ResourcePolicy { + max_key_import_kdf_memory_bytes: 1, + ..ResourcePolicy::default() + }; + let bytes = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + assert!(matches!( + KeyInventory::default().add_pkcs12("limited".into(), bytes, "secret", &resources), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::KEY_IMPORT_KDF_MEMORY, + maximum: 1, + } + )) + )); + } + + #[test] + fn pkcs12_temporary_memory_shares_existing_inventory_budget() { + // Encoded input fits, but decrypted buffers and retained vector slots + // must not receive a fresh aggregate allowance beside existing keys. + let resources = ResourcePolicy { + max_external_resource_bytes: 3000, + max_external_resource_total_bytes: 5000, + ..ResourcePolicy::default() + }; + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "hmac".into(), + SymmetricKeyKind::Hmac, + vec![1; 2000], + KeyUsages::SIGN, + &resources, + ) + .expect("existing key fits"); + let bytes = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + assert!(matches!( + inventory.add_pkcs12("bundle".into(), bytes, "secret", &resources), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + maximum: 5000 + } + )) + )); + assert_eq!(inventory.symmetric_keys().len(), 1); + assert!(inventory.private_keys().is_empty()); + } + + #[test] + fn private_bundle_certificates_do_not_authorize_verification() { + // A SIGN/DECRYPT-only PKCS#12 bundle must not implicitly make its + // associated leaf available as a verification lookup candidate. + let bytes = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_pkcs12("private".into(), bytes, "secret", &resources) + .expect("bundle imports"); + let leaf = inventory.private_keys()[0].certificate_chain[0].clone(); + let subject = crate::xmldsig::parse::parse_x509_certificate(&leaf) + .expect("bundle leaf parses") + .subject_dn; + let key_info = KeyInfo { + sources: vec![KeyInfoSource::X509Data(X509DataInfo { + subject_names: vec![subject], + ..X509DataInfo::default() + })], + }; + let resolver = inventory.verification_resolver(); + assert!( + resolver + .resolve(Some(&key_info), SignatureAlgorithm::RsaSha256) + .expect("lookup completes") + .is_none() + ); + + inventory + .add_certificate_der(leaf, false, &resources) + .expect("explicit lookup certificate imports"); + assert!( + inventory + .verification_resolver() + .resolve(Some(&key_info), SignatureAlgorithm::RsaSha256) + .expect("explicit lookup completes") + .is_some() + ); + } + + #[test] + fn stored_signing_keys_obey_operation_material_limits() { + // An import-time resource policy must not override stricter limits + // selected for a later signing operation. + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "hmac".into(), + SymmetricKeyKind::Hmac, + b"0123456789abcdef0123456789abcdef".to_vec(), + KeyUsages::SIGN, + &resources, + ) + .expect("HMAC imports"); + inventory + .add_private_pem( + "rsa".into(), + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-key.pem"), + None, + KeyUsages::SIGN, + &resources, + ) + .expect("RSA imports"); + + for (name, algorithm, length) in [ + ( + "hmac", + SignatureAlgorithm::HmacSha256, + inventory.symmetric_keys[0].bytes.len(), + ), + ( + "rsa", + SignatureAlgorithm::RsaSha256, + inventory.private_keys[0].pkcs8_der.len(), + ), + ] { + let mut policy = crate::policy::SigningPolicy::default(); + policy.resources.max_external_resource_bytes = length - 1; + assert!(matches!( + inventory.signing_key(name, algorithm, &policy), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum, + actual, + } + )) if maximum == length - 1 && actual == length + )); + + policy.resources.max_external_resource_bytes = length; + policy.resources.max_external_resource_total_bytes = length - 1; + assert!(matches!( + inventory.signing_key(name, algorithm, &policy), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + maximum, + actual, + } + )) if maximum == length - 1 && actual == length + )); + } + } + + #[test] + fn named_signing_lookup_obeys_active_candidate_budget() { + // Import limits do not authorize a later operation to scan the full inventory. + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + for name in ["first", "second"] { + inventory + .add_symmetric( + name.into(), + SymmetricKeyKind::Hmac, + vec![0x42; 32], + KeyUsages::SIGN, + &resources, + ) + .expect("HMAC imports"); + } + let mut policy = crate::policy::SigningPolicy::default(); + policy.resources.max_key_candidates = 1; + assert!( + inventory + .signing_key("first", SignatureAlgorithm::HmacSha256, &policy) + .is_ok() + ); + assert!(matches!( + inventory.signing_key("second", SignatureAlgorithm::HmacSha256, &policy), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::KEY_CANDIDATES, + .. + } + )) + )); + } + + #[cfg(feature = "xmlenc")] + #[test] + fn named_decryption_lookup_shares_candidate_budget_across_key_kinds() { + // Scanning a symmetric miss must consume the same operation budget as + // the subsequent private-key lookup. + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "aes".into(), + SymmetricKeyKind::Aes, + vec![0x42; 32], + KeyUsages::DECRYPT, + &resources, + ) + .expect("AES imports"); + inventory + .add_private_pem( + "rsa".into(), + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-key.pem"), + None, + KeyUsages::DECRYPT, + &resources, + ) + .expect("RSA imports"); + let mut policy = crate::policy::DecryptionPolicy::default(); + policy.resources.max_key_candidates = 1; + assert!(inventory.decryption_resolver("aes", &policy).is_ok()); + assert!(matches!( + inventory.decryption_resolver("rsa", &policy), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::KEY_CANDIDATES, + .. + } + )) + )); + } + + #[test] + fn pkcs12_imports_share_candidate_budget() { + // A key plus its retained certificate consumes two inventory slots. + let bundle = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + let resources = ResourcePolicy { + max_key_candidates: 3, + ..ResourcePolicy::default() + }; + let mut inventory = KeyInventory::default(); + inventory + .add_pkcs12("first".into(), bundle, "secret", &resources) + .expect("first bundle fits"); + assert_eq!(inventory.entry_count(), 2); + assert!( + inventory.private_keys()[0] + .matching_certificate_chain() + .is_some() + ); + assert!( + inventory + .add_pkcs12("second".into(), bundle, "secret", &resources) + .is_err() + ); + } + + #[test] + fn pkcs12_input_consumes_aggregate_import_budget() { + // Repeated containers must charge encoded bytes, even when decoded material is small. + let bundle = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_pkcs12("first".into(), bundle, "secret", &resources) + .expect("first bundle imports"); + assert!(inventory.material_bytes >= bundle.len()); + let limited = ResourcePolicy { + max_external_resource_total_bytes: bundle.len() * 2 - 1, + ..resources + }; + assert!( + inventory + .add_pkcs12("second".into(), bundle, "secret", &limited) + .is_err() + ); + } + + #[test] + fn pkcs12_unrelated_ca_does_not_block_private_key() { + // Generated with OpenSSL from the tracked RSA key and unrelated CA; + // the CA is lookup material, not a fabricated leaf certificate. + let bundle = base64::engine::general_purpose::STANDARD + .decode( + include_str!("../tests/fixtures/keys/pkcs12/rsa-key-unrelated-ca.p12.b64").trim(), + ) + .expect("fixture base64 decodes"); + let mut inventory = KeyInventory::default(); + inventory + .add_pkcs12( + "ca-only".into(), + &bundle, + "secret", + &ResourcePolicy::default(), + ) + .expect("unrelated CA must not invalidate the private key"); + assert_eq!(inventory.private_keys.len(), 1); + assert_eq!(inventory.private_keys[0].certificate_chain.len(), 1); + assert!(!inventory.private_keys[0].has_matching_leaf); + assert!( + inventory + .signing_key( + "ca-only", + SignatureAlgorithm::RsaSha256, + &crate::policy::SigningPolicy::default(), + ) + .is_ok() + ); + } + + #[test] + fn pkcs12_identical_leaf_bags_are_one_candidate() { + // OpenSSL exported the same certificate as both the leaf and an extra + // cert bag; the inventory retains one copy for the matching key. + let bundle = base64::engine::general_purpose::STANDARD + .decode(include_str!("../tests/fixtures/keys/pkcs12/rsa-duplicate-leaf.p12.b64").trim()) + .expect("fixture base64 decodes"); + let mut inventory = KeyInventory::default(); + inventory + .add_pkcs12( + "duplicate-leaf".into(), + &bundle, + "secret", + &ResourcePolicy::default(), + ) + .expect("identical leaf bags are not ambiguous"); + assert_eq!(inventory.private_keys[0].certificate_chain.len(), 1); + assert!(inventory.private_keys[0].has_matching_leaf); + } + + #[test] + fn ec_pkcs12_import_is_sign_only() { + // The convenience importer must not advertise RSA transport for EC. + let bundle = base64::engine::general_purpose::STANDARD + .decode(include_str!("../tests/fixtures/keys/pkcs12/ec-key.p12.b64").trim()) + .expect("fixture base64 decodes"); + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_pkcs12("ec".into(), &bundle, "secret", &resources) + .expect("EC signing bundle imports"); + assert!(inventory.private_keys()[0].usages.allows(KeyUsage::Sign)); + assert!(!inventory.private_keys()[0].usages.allows(KeyUsage::Decrypt)); + assert!( + KeyInventory::default() + .add_pkcs12_with_usages( + "ec".into(), + &bundle, + "secret", + KeyUsages::DECRYPT, + &resources, + ) + .is_err() + ); + } + + #[test] + fn password_callback_runs_for_protected_container() { + // Password delivery is caller-owned and failures must not leak the + // callback's diagnostic or retry a plaintext decoder. + let bundle = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + let mut inventory = KeyInventory::default(); + inventory + .add_pkcs12_with_password_callback( + "bundle".into(), + bundle, + || Some(Zeroizing::new("secret".to_owned())), + KeyUsages::SIGN, + &ResourcePolicy::default(), + ) + .expect("callback password decrypts donor bundle"); + assert!(inventory.private_keys[0].usages.allows(KeyUsage::Sign)); + assert!(matches!( + KeyInventory::default().add_pkcs12_with_password_callback( + "bundle".into(), + bundle, + || None, + KeyUsages::SIGN, + &ResourcePolicy::default(), + ), + Err(KeyStoreError::ProtectedContainer) + )); + let limited = ResourcePolicy { + max_external_resource_total_bytes: bundle.len() - 1, + ..ResourcePolicy::default() + }; + assert!(matches!( + KeyInventory::default().add_pkcs12_with_password_callback( + "bundle".into(), + bundle, + || panic!("budget must be checked before password delivery"), + KeyUsages::SIGN, + &limited, + ), + Err(KeyStoreError::Selection(_)) + )); + let limited_kdf = ResourcePolicy { + max_key_import_kdf_work: 1, + ..ResourcePolicy::default() + }; + assert!(matches!( + KeyInventory::default().add_pkcs12_with_password_callback( + "bundle".into(), + bundle, + || panic!("KDF denial must precede password delivery"), + KeyUsages::SIGN, + &limited_kdf, + ), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::KdfIterationsOutsideLimit { maximum: 1 } + )) + )); + let oversized_bundle = ResourcePolicy { + max_external_resource_bytes: bundle.len() - 1, + ..ResourcePolicy::default() + }; + assert!(matches!( + KeyInventory::default().add_pkcs12_with_password_callback( + "bundle".into(), + bundle, + || panic!("size denial must precede password delivery"), + KeyUsages::SIGN, + &oversized_bundle, + ), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum, + } + )) if maximum == bundle.len() - 1 + )); + } + + #[test] + fn pkcs12_callback_preflights_indefinite_outer_ber() { + // The outer PFX may use BER indefinite length without changing MAC parameters. + let bundle = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + assert_eq!(bundle[0], 0x30); + let length_octets = usize::from(bundle[1] & 0x7f); + assert!(bundle[1] & 0x80 != 0 && length_octets > 0); + let mut ber = vec![0x30, 0x80]; + ber.extend_from_slice(&bundle[2 + length_octets..]); + ber.extend_from_slice(&[0, 0]); + let resources = ResourcePolicy { + max_key_import_kdf_work: 1, + ..ResourcePolicy::default() + }; + assert!(matches!( + KeyInventory::default().add_pkcs12_with_password_callback( + "bundle".into(), + &ber, + || panic!("BER MAC KDF denial must precede password delivery"), + KeyUsages::SIGN, + &resources, + ), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::KdfIterationsOutsideLimit { maximum: 1 } + )) + )); + } + + #[test] + fn donor_xml_store_preserves_private_dsa_material() { + // xmlsec's non-standard DSA X must be checked against Y, not silently + // dropped while presenting the named key as usable for signing. + let bytes = include_bytes!("../tests/fixtures/keys/xmlsec/mixed-keys.xml"); + let inventory = KeyInventory::from_xml_bytes( + bytes, + &xml_policy(ResourcePolicy::default()), + XmlBackend::default(), + ) + .expect("donor key store should parse"); + let dsa = inventory + .private_keys + .iter() + .find(|entry| entry.name == "test-dsa") + .expect("DSA private X should be imported"); + assert!(dsa.usages.allows(KeyUsage::Sign)); + assert!(!dsa.usages.allows(KeyUsage::Decrypt)); + assert!(DsaSigningKey::from_pkcs8_der(&dsa.pkcs8_der).is_ok()); + let dsa_public = inventory + .public_keys() + .iter() + .find(|entry| entry.name == "test-dsa") + .expect("DSA public entry is retained"); + assert_eq!(dsa_public.usages, KeyUsages::VERIFY); + } + + #[test] + fn xml_store_charges_retained_decoded_material() { + // DSA retains public components and a derived private PKCS#8 buffer. + let source = include_str!("../tests/fixtures/keys/xmlsec/mixed-keys.xml"); + let marker = source.find("test-dsa").expect("DSA key"); + let start = source[..marker].rfind("").expect("DSA end") + "".len(); + let xml = format!( + "{}", + source[start..end].replace('\n', "") + ); + let inventory = KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(ResourcePolicy::default()), + XmlBackend::default(), + ) + .expect("compact DSA store imports"); + let retained = inventory.retained_material_bytes().expect("bounded tally"); + assert_eq!(inventory.material_bytes, xml.len().max(retained)); + assert!(retained >= inventory.private_keys[0].pkcs8_der.len()); + } + + #[test] + fn xml_store_rejects_empty_symmetric_material() { + // Empty decoded secrets are invalid at the same boundary as direct imports. + for kind in ["HMACKeyValue", "AESKeyValue", "DESKeyValue"] { + let xml = format!( + "empty<{kind} xmlns=\"{XMLSEC_NS}\"/>" + ); + assert!( + KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(ResourcePolicy::default()), + XmlBackend::default(), + ) + .is_err() + ); + } + } + + #[test] + fn aes_imports_require_supported_key_widths() { + // Both direct and XML key stores must reject widths unusable by AES-CBC/GCM. + let resources = ResourcePolicy::default(); + for length in [1, 15, 17, 23, 25, 31, 33] { + let mut inventory = KeyInventory::default(); + assert!( + inventory + .add_symmetric( + "aes".into(), + SymmetricKeyKind::Aes, + vec![0; length], + KeyUsages::ENCRYPT, + &resources, + ) + .is_err() + ); + let xml = format!( + "aes{}", + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, vec![0; length]) + ); + assert!( + KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(resources.clone()), + XmlBackend::default(), + ) + .is_err() + ); + } + for length in [16, 24, 32] { + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "aes".into(), + SymmetricKeyKind::Aes, + vec![0; length], + KeyUsages::ENCRYPT, + &resources, + ) + .expect("AES-128/192/256 key imports"); + } + } + + #[test] + fn unsupported_des_material_is_not_authorized() { + // A parsed legacy DES value must not advertise an operation that the + // encryption pipeline cannot execute. + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + assert!( + inventory + .add_symmetric( + "des".into(), + SymmetricKeyKind::Des, + vec![0x42; 8], + KeyUsages::ENCRYPT, + &resources, + ) + .is_err() + ); + let xml = format!( + "desQkJCQkJCQkI=" + ); + assert!( + KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(resources.clone()), + XmlBackend::default(), + ) + .is_err() + ); + } + + #[test] + fn xml_store_accepts_xmlsig11_ec_key_value() { + // XMLDSig 1.1 ECKeyValue must be recognized as public material. + let pem = pem::parse(include_bytes!( + "../tests/fixtures/keys/ec/ec-prime256v1-pubkey.pem" + )) + .expect("EC public PEM decodes"); + let (_, spki) = x509_parser::x509::SubjectPublicKeyInfo::from_der(pem.contents()) + .expect("EC SPKI parses"); + let point = + base64::engine::general_purpose::STANDARD.encode(spki.subject_public_key.data.as_ref()); + let xml = format!( + "ec{point}" + ); + let store = KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(ResourcePolicy::default()), + XmlBackend::default(), + ) + .expect("ECKeyValue namespace is supported"); + assert_eq!(store.public_keys().len(), 1); + assert_eq!(store.public_keys()[0].usages, KeyUsages::VERIFY); + assert!( + store.public_keys()[0] + .key_info + .sources + .iter() + .any(|source| matches!(source, KeyInfoSource::KeyValue(KeyValueInfo::Ec { .. }))) + ); + } + + #[test] + fn xml_store_rejects_unusable_public_key_values() { + // Malformed supported public-key material must fail at import, not at verification. + let cases = [ + "AQAB", + "AQ==", + ]; + for key in cases { + let xml = format!( + "invalid{key}" + ); + assert!( + KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(ResourcePolicy::default()), + XmlBackend::default(), + ) + .is_err() + ); + } + } + + #[test] + fn xml_store_enforces_all_parser_resource_limits() { + // Import must not skip the depth, namespace or cumulative work limits. + let xml = format!( + "keyc2VjcmV0" + ); + for resources in [ + ResourcePolicy { + max_xml_depth: 2, + ..ResourcePolicy::default() + }, + ResourcePolicy { + max_xml_namespace_bindings: 1, + ..ResourcePolicy::default() + }, + ResourcePolicy { + max_xml_parse_work_bytes: 1, + ..ResourcePolicy::default() + }, + ] { + assert!( + KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(resources), + XmlBackend::default() + ) + .is_err(), + "parser limit must apply to key stores" + ); + } + } + + #[test] + fn xml_store_bounds_source_before_utf16_decode() { + // The source-byte ceiling must be checked before UTF-16 expansion or parsing. + let xml = format!(""); + let mut bytes = vec![0xff, 0xfe]; + for unit in xml.encode_utf16() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + let resources = ResourcePolicy { + max_xml_document_bytes: xml.len() + 1, + ..ResourcePolicy::default() + }; + assert!(bytes.len() > resources.max_xml_document_bytes); + assert!( + KeyInventory::from_xml_bytes(&bytes, &xml_policy(resources), XmlBackend::default()) + .is_err() + ); + } + + #[test] + fn xml_store_uses_operation_xml_policy() { + // Internal DTD permission must come from the operation snapshot, not + // an importer-local default that rejects a caller-authorized store. + let xml = format!( + "]>&key;c2VjcmV0" + ); + let denied = crate::policy::VerificationPolicy::default(); + assert!( + KeyInventory::from_xml_bytes(xml.as_bytes(), &denied, XmlBackend::default()).is_err() + ); + let mut allowed = denied; + allowed.xml.allow_internal_dtd = true; + assert!( + KeyInventory::from_xml_bytes(xml.as_bytes(), &allowed, XmlBackend::default()).is_ok() + ); + } + + #[test] + fn ec_public_key_cannot_authorize_encryption() { + // EC material can verify signatures but cannot serve as an RSA recipient. + let resources = ResourcePolicy::default(); + let ec = include_bytes!("../tests/fixtures/keys/ec/ec-prime256v1-pubkey.pem"); + let cert = include_bytes!("../tests/fixtures/keys/ec/ec-prime256v1-cert.pem"); + let mut store = KeyInventory::default(); + store + .add_public_pem("ec".into(), ec, &resources) + .expect("EC public key imports"); + assert_eq!(store.public_keys()[0].usages, KeyUsages::VERIFY); + assert!( + store + .add_public_pem_with_usages("ec-encrypt".into(), ec, KeyUsages::ENCRYPT, &resources) + .is_err() + ); + let cert_der = pem::parse(cert) + .expect("EC certificate PEM decodes") + .into_contents(); + store + .add_public_der("ec-cert".into(), cert_der.clone(), &resources) + .expect("EC certificate imports"); + assert_eq!(store.public_keys()[1].usages, KeyUsages::VERIFY); + assert!( + store + .add_public_der_with_usages( + "ec-cert-encrypt".into(), + cert_der, + KeyUsages::ENCRYPT, + &resources + ) + .is_err() + ); + } + + #[test] + fn xml_store_rejects_policy_above_absolute_ceiling() { + // Public imports cannot bypass hard ceilings with an unvalidated policy. + let resources = ResourcePolicy { + max_xml_nodes: crate::hard_limits::XML_DOCUMENT_NODE_CEILING as usize + 1, + ..ResourcePolicy::default() + }; + let xml = format!(""); + assert!( + KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(resources.clone()), + XmlBackend::default(), + ) + .is_err() + ); + assert!( + KeyInventory::default() + .add_symmetric( + "key".into(), + SymmetricKeyKind::Hmac, + b"secret".to_vec(), + KeyUsages::SIGN, + &resources, + ) + .is_err() + ); + } + + #[test] + fn ec_private_key_cannot_advertise_rsa_decryption() { + // Only RSA private material can satisfy the inventory's decrypt API. + let pem = include_bytes!("../tests/fixtures/keys/ec/ec-prime256v1-key.pem"); + assert!( + KeyInventory::default() + .add_private_pem( + "ec".into(), + pem, + None, + KeyUsages::DECRYPT, + &ResourcePolicy::default(), + ) + .is_err() + ); + } + + #[test] + fn imports_rsa_pkcs1_pem_and_resolves_named_spki() { + // Traditional RSA import and named public lookup share one inventory, + // while the resolver still applies the operation's verification policy. + let private_pem = include_str!("../tests/fixtures/keys/rsa/rsa-2048-key.pem"); + let rsa = RsaPrivateKey::from_pkcs8_pem(private_pem).expect("RSA fixture parses"); + let pkcs1 = rsa.to_pkcs1_der().expect("RSA fixture encodes as PKCS#1"); + let public = rsa + .to_public_key() + .to_public_key_der() + .expect("RSA fixture has SPKI"); + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_private_der( + "key".into(), + pkcs1.as_bytes(), + None, + KeyUsages::SIGN.union(KeyUsages::DECRYPT), + &resources, + ) + .expect("PKCS#1 private key imports"); + inventory + .add_public_der("verify-key".into(), public.as_bytes().to_vec(), &resources) + .expect("public SPKI imports"); + assert!(inventory.private_keys[0].usages.allows(KeyUsage::Sign)); + let mut info = KeyInfo::default(); + info.sources + .push(KeyInfoSource::KeyName("verify-key".into())); + let resolver = inventory.verification_resolver(); + let resolved = resolver + .resolve(Some(&info), SignatureAlgorithm::RsaSha256) + .expect("named public key resolves"); + assert!(resolved.is_some()); + } + + #[test] + fn rejects_unknown_pem_text_and_duplicate_names() { + // PEM is a single armor block; unrelated trailing data must not be + // silently skipped by the general-purpose PEM parser. + let public = include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"); + let mut inventory = KeyInventory::default(); + let resources = ResourcePolicy::default(); + inventory + .add_public_pem("first".into(), public, &resources) + .expect("public PEM imports"); + assert!(matches!( + inventory.add_public_pem("first".into(), public, &resources), + Err(KeyStoreError::Selection("duplicate key name")) + )); + let mut trailing = public.to_vec(); + trailing.extend_from_slice(b"\nnot a key"); + assert!( + inventory + .add_public_pem("other".into(), &trailing, &resources) + .is_err() + ); + } + + #[test] + fn rsa_spki_import_rejects_even_public_exponent() { + // ASN.1 shape alone must not grant verify/encrypt usages to an unusable RSA key. + let mut der = single_pem_block( + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"), + ResourcePolicy::default().max_external_resource_bytes, + ) + .expect("public key fixture") + .into_contents(); + let exponent = der + .windows(5) + .position(|bytes| bytes == [0x02, 0x03, 0x01, 0x00, 0x01]) + .expect("RSA exponent in SPKI"); + der[exponent + 4] = 0; + assert!( + KeyInventory::default() + .add_public_der("invalid".into(), der, &ResourcePolicy::default()) + .is_err() + ); + } + + #[test] + fn rsa_public_pkcs1_pem_is_bounded_before_bigint_decode() { + // The borrowed ASN.1 modulus is checked before RSA allocates integers. + let modulus = vec![1_u8; crate::hard_limits::RSA_MODULUS_BIT_CEILING.div_ceil(8) + 1]; + let exponent = [1_u8, 0, 1]; + let public = rsa::pkcs1::RsaPublicKey { + modulus: der::asn1::UintRef::new(&modulus).expect("valid modulus"), + public_exponent: der::asn1::UintRef::new(&exponent).expect("valid exponent"), + }; + let pem = pem::encode(&pem::Pem::new( + "RSA PUBLIC KEY", + der::Encode::to_der(&public).expect("encodable RSA public key"), + )); + assert!(matches!( + KeyInventory::default().add_public_pem( + "oversized".into(), + pem.as_bytes(), + &ResourcePolicy::default(), + ), + Err(KeyStoreError::Selection( + "RSA public key exceeds safety limit" + )) + )); + } + + #[test] + fn direct_inventory_names_consume_resource_budget() { + // Caller-owned names must not bypass per-resource or retained aggregate bounds. + let resources = ResourcePolicy { + max_external_resource_bytes: 64, + max_external_resource_total_bytes: 100, + ..ResourcePolicy::default() + }; + let mut inventory = KeyInventory::default(); + assert!( + inventory + .add_symmetric( + "x".repeat(65), + SymmetricKeyKind::Hmac, + vec![7; 32], + KeyUsages::SIGN, + &resources, + ) + .is_err() + ); + inventory + .add_symmetric( + "a".repeat(40), + SymmetricKeyKind::Hmac, + vec![7; 32], + KeyUsages::SIGN, + &resources, + ) + .expect("first named key fits"); + assert!( + inventory + .add_symmetric( + "b".repeat(40), + SymmetricKeyKind::Hmac, + vec![8; 32], + KeyUsages::SIGN, + &resources, + ) + .is_err() + ); + } + + #[test] + fn encrypted_pkcs8_requires_correct_password_without_plaintext_fallback() { + // Wrong passwords must not retry another format or leave a partial + // registration in the caller-owned inventory. + let private_pem = include_str!("../tests/fixtures/keys/rsa/rsa-2048-key.pem"); + let rsa = RsaPrivateKey::from_pkcs8_pem(private_pem).expect("RSA fixture parses"); + let plain = rsa.to_pkcs8_der().expect("RSA fixture encodes as PKCS#8"); + let mut rng = ChaCha8Rng::seed_from_u64(0xA11C_E501); + let encrypted = PrivateKeyInfoRef::try_from(plain.as_bytes()) + .expect("PKCS#8 reference parses") + .encrypt_with_rng(&mut rng, b"correct") + .expect("PKCS#8 fixture encrypts"); + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + let restricted = ResourcePolicy { + max_key_import_kdf_work: 1, + ..ResourcePolicy::default() + }; + assert!(matches!( + inventory.add_private_der( + "restricted".into(), + encrypted.as_bytes(), + Some(b"correct"), + KeyUsages::SIGN, + &restricted, + ), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + maximum: 1, + .. + } + )) + )); + let callback_calls = std::cell::Cell::new(0); + assert!(matches!( + inventory.add_private_der_with_password_callback( + "restricted-callback".into(), + encrypted.as_bytes(), + || { + callback_calls.set(callback_calls.get() + 1); + Some(Zeroizing::new(b"correct".to_vec())) + }, + KeyUsages::SIGN, + &restricted, + ), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + maximum: 1, + .. + } + )) + )); + assert_eq!(callback_calls.get(), 0); + assert!(matches!( + inventory.add_private_der( + "protected".into(), + encrypted.as_bytes(), + Some(b"wrong"), + KeyUsages::SIGN, + &resources, + ), + Err(KeyStoreError::ProtectedContainer) + )); + assert!(inventory.private_keys.is_empty()); + inventory + .add_private_der( + "protected".into(), + encrypted.as_bytes(), + Some(b"correct"), + KeyUsages::SIGN, + &resources, + ) + .expect("correct password imports encrypted PKCS#8"); + assert_eq!(inventory.private_keys.len(), 1); + assert!(inventory.material_bytes >= encrypted.as_bytes().len()); + let mut callback_inventory = KeyInventory::default(); + callback_inventory + .add_private_der_with_password_callback( + "callback".into(), + encrypted.as_bytes(), + || Some(Zeroizing::new(b"correct".to_vec())), + KeyUsages::SIGN, + &resources, + ) + .expect("callback decrypts encrypted PKCS#8"); + callback_inventory + .add_private_der_with_password_callback( + "plain".into(), + plain.as_bytes(), + || panic!("plaintext PKCS#8 must not request a password"), + KeyUsages::SIGN, + &resources, + ) + .expect("plaintext import ignores password callback"); + } + + #[test] + fn scrypt_parallel_buffers_are_checked_before_derivation() { + // N*r fits a tiny limit, but p independent B/V/T workspaces do not. + let mut resources = ResourcePolicy { + max_key_import_kdf_work: 10_000, + max_key_import_kdf_memory_bytes: 4_096, + ..ResourcePolicy::default() + }; + assert!(matches!( + enforce_scrypt_kdf_limits(2, 1, 1_000, &resources), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::KEY_IMPORT_KDF_MEMORY, + maximum: 4_096, + actual: 512_000, + } + )) + )); + resources.max_key_import_kdf_memory_bytes = 512_000; + assert!(enforce_scrypt_kdf_limits(2, 1, 1_000, &resources).is_ok()); + } + + #[test] + fn named_hmac_resolution_enforces_usage_and_method() { + // A named secret may verify only when both its usage and the XMLDSig + // method permit HMAC; the resolver must not fall back to another key. + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "verify".into(), + SymmetricKeyKind::Hmac, + b"sufficiently-long-hmac-secret".to_vec(), + KeyUsages::VERIFY, + &resources, + ) + .expect("verification HMAC imports"); + inventory + .add_symmetric( + "sign-only".into(), + SymmetricKeyKind::Hmac, + b"another-hmac-secret".to_vec(), + KeyUsages::SIGN, + &resources, + ) + .expect("sign-only HMAC imports"); + let resolver = inventory.verification_resolver(); + let named = |name: &str| KeyInfo { + sources: vec![KeyInfoSource::KeyName(name.into())], + ..KeyInfo::default() + }; + assert!( + resolver + .resolve(Some(&named("verify")), SignatureAlgorithm::HmacSha256) + .expect("named HMAC resolves") + .is_some() + ); + assert!( + resolver + .resolve(Some(&named("verify")), SignatureAlgorithm::RsaSha256) + .is_err() + ); + assert!( + resolver + .resolve(Some(&named("sign-only")), SignatureAlgorithm::HmacSha256) + .is_err() + ); + } + + #[test] + fn named_hmac_resolution_rejects_invalid_policy_snapshot() { + // Early HMAC resolution must validate the entire snapshot even when + // the selected key is small and otherwise permitted. + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "verify".into(), + SymmetricKeyKind::Hmac, + b"sufficiently-long-hmac-secret".to_vec(), + KeyUsages::VERIFY, + &ResourcePolicy::default(), + ) + .expect("verification HMAC imports"); + let info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("verify".into())], + ..KeyInfo::default() + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.resources.max_external_resource_bytes = usize::MAX; + assert!(matches!( + inventory.verification_resolver().resolve_with_policy( + Some(&info), + SignatureAlgorithm::HmacSha256, + &policy + ), + Err(DsigError::Policy(_)) + )); + } + + #[test] + fn named_hmac_resolution_uses_active_resource_limits() { + // A store imported under a broad policy must not bypass a later, + // stricter verification snapshot when resolving a named secret. + let resources = ResourcePolicy::default(); + let secret = b"sufficiently-long-hmac-secret"; + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "verify".into(), + SymmetricKeyKind::Hmac, + secret.to_vec(), + KeyUsages::VERIFY, + &resources, + ) + .expect("verification HMAC imports"); + let key_info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("verify".into())], + ..KeyInfo::default() + }; + let resolver = inventory.verification_resolver(); + for (resource, aggregate) in [ + (crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, false), + ( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + true, + ), + ] { + let mut policy = crate::policy::VerificationPolicy::default(); + if aggregate { + policy.resources.max_external_resource_total_bytes = secret.len() - 1; + } else { + policy.resources.max_external_resource_bytes = secret.len() - 1; + } + let error = resolver + .resolve_with_policy(Some(&key_info), SignatureAlgorithm::HmacSha256, &policy) + .err() + .expect("active limit must reject stored HMAC material"); + assert!( + matches!( + error, + DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: actual, + maximum, + actual: size, + }) if actual == resource && maximum == secret.len() - 1 && size == secret.len() + ), + "{error:?}" + ); + } + } + + #[test] + fn one_named_verification_entry_fits_one_candidate() { + // A name and the single entry it selects are one lookup, not two. + let mut inventory = KeyInventory::default(); + let mut policy = crate::policy::VerificationPolicy::default(); + inventory + .add_symmetric( + "only".into(), + SymmetricKeyKind::Hmac, + b"sufficiently-long-hmac-secret".to_vec(), + KeyUsages::VERIFY, + &policy.resources, + ) + .expect("HMAC imports"); + policy.resources.max_key_candidates = 1; + let info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("only".into())], + }; + assert!( + inventory + .verification_resolver() + .resolve_with_policy(Some(&info), SignatureAlgorithm::HmacSha256, &policy) + .expect("one lookup fits") + .is_some() + ); + } + + #[test] + fn pem_imports_charge_encoded_input_to_aggregate_budget() { + // Repeated padded PEM inputs must consume the aggregate work budget + // even when their decoded DER keys are much smaller. + let public = include_str!("../tests/fixtures/keys/rsa/rsa-4096-pubkey.pem"); + let private = include_str!("../tests/fixtures/keys/rsa/rsa-4096-key.pem"); + for (pem, is_private) in [(public, false), (private, true)] { + let padded = format!("{pem}{}", " ".repeat(16 * 1024)); + let resources = ResourcePolicy { + max_external_resource_bytes: padded.len(), + max_external_resource_total_bytes: padded.len() + + if is_private { 5 } else { 10 } + + 1, + ..ResourcePolicy::default() + }; + let mut inventory = KeyInventory::default(); + let import = |inventory: &mut KeyInventory, name: &str| { + if is_private { + inventory.add_private_pem( + name.into(), + padded.as_bytes(), + None, + KeyUsages::SIGN, + &resources, + ) + } else { + inventory.add_public_pem(name.into(), padded.as_bytes(), &resources) + } + }; + import(&mut inventory, "first").expect("first PEM import fits"); + assert!( + matches!( + import(&mut inventory, "second"), + Err(KeyStoreError::Selection( + "key material total exceeds resource limit" + )) + ), + "second PEM input must exceed aggregate budget" + ); + } + } + + #[test] + fn repeated_key_name_selects_one_inventory_entry() { + // XMLDSig 1.1 section 4.5 permits repeated KeyInfo choices; duplicate + // references to one entry are not two distinct verification keys. + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "secret".into(), + SymmetricKeyKind::Hmac, + b"sufficiently-long-hmac-secret".to_vec(), + KeyUsages::VERIFY, + &resources, + ) + .expect("HMAC key imports"); + inventory + .add_symmetric( + "other-secret".into(), + SymmetricKeyKind::Hmac, + b"another-long-hmac-secret".to_vec(), + KeyUsages::VERIFY, + &resources, + ) + .expect("second HMAC key imports"); + inventory + .add_public_pem( + "public".into(), + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"), + &resources, + ) + .expect("public key imports"); + let resolver = inventory.verification_resolver(); + for (name, algorithm) in [ + ("secret", SignatureAlgorithm::HmacSha256), + ("public", SignatureAlgorithm::RsaSha256), + ] { + let info = KeyInfo { + sources: vec![ + KeyInfoSource::KeyName(name.into()), + KeyInfoSource::KeyName(name.into()), + ], + }; + assert!(resolver.resolve(Some(&info), algorithm).is_ok(), "{name}"); + } + let distinct = KeyInfo { + sources: vec![ + KeyInfoSource::KeyName("secret".into()), + KeyInfoSource::KeyName("other-secret".into()), + ], + }; + assert!( + resolver + .resolve(Some(&distinct), SignatureAlgorithm::HmacSha256) + .is_err() + ); + } + + #[test] + fn named_lookup_charges_inspected_inventory_entries() { + // A late KeyName must not bypass a stricter operation candidate limit. + let mut inventory = KeyInventory::default(); + let resources = ResourcePolicy::default(); + for name in ["first", "second"] { + inventory + .add_symmetric( + name.into(), + SymmetricKeyKind::Hmac, + b"sufficiently-long-hmac-secret".to_vec(), + KeyUsages::VERIFY, + &resources, + ) + .expect("key imports"); + } + let key_info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("second".into())], + ..KeyInfo::default() + }; + let policy = crate::policy::VerificationPolicy { + resources: ResourcePolicy { + max_key_candidates: 1, + ..ResourcePolicy::default() + }, + ..crate::policy::VerificationPolicy::default() + }; + let resolver = inventory.verification_resolver(); + assert!( + resolver + .resolve_with_policy_and_provider( + Some(&key_info), + SignatureAlgorithm::HmacSha256, + &policy, + crate::provider::default_provider(), + ) + .is_err() + ); + } + + #[test] + fn private_import_rejects_public_only_usage() { + // Usage is part of the inventory contract: nonsensical permissions + // must not survive import and later be interpreted by a resolver. + let private = include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-key.pem"); + let mut inventory = KeyInventory::default(); + assert!( + inventory + .add_private_pem( + "wrong-use".into(), + private, + None, + KeyUsages::SIGN.union(KeyUsages::VERIFY), + &ResourcePolicy::default(), + ) + .is_err() + ); + assert!(inventory.private_keys.is_empty()); + } + + #[test] + fn normalized_private_key_must_fit_resource_limit() { + // PKCS#8 wrapping may cross the per-resource ceiling even when PKCS#1 fits. + let rsa = RsaPrivateKey::from_pkcs8_pem(include_str!( + "../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("RSA fixture parses"); + let pkcs1 = rsa.to_pkcs1_der().expect("PKCS#1 encodes"); + let pkcs8 = rsa.to_pkcs8_der().expect("PKCS#8 encodes"); + assert!(pkcs8.as_bytes().len() > pkcs1.as_bytes().len()); + let resources = ResourcePolicy { + max_external_resource_bytes: pkcs1.as_bytes().len(), + ..ResourcePolicy::default() + }; + let mut inventory = KeyInventory::default(); + assert!( + inventory + .add_private_der( + "rsa".into(), + pkcs1.as_bytes(), + None, + KeyUsages::SIGN, + &resources, + ) + .is_err() + ); + assert!(inventory.private_keys.is_empty()); + } + + #[test] + fn oversized_rsa_components_are_rejected_before_private_key_decode() { + // PKCS#8 wraps PKCS#1 integers; every component must be checked + // before RustCrypto allocates big integers or validates CRT arithmetic. + let pem = include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-key.pem"); + let block = single_pem_block(pem, ResourcePolicy::default().max_external_resource_bytes) + .expect("fixture PEM"); + let info = PrivateKeyInfoRef::try_from(block.contents()).expect("fixture PKCS#8"); + let original = rsa::pkcs1::RsaPrivateKey::from_der(info.private_key.as_bytes()) + .expect("fixture PKCS#1"); + let oversized_modulus = vec![1_u8; 1025]; + let oversized = rsa::pkcs1::RsaPrivateKey { + modulus: der::asn1::UintRef::new(&oversized_modulus).expect("positive modulus"), + public_exponent: original.public_exponent, + private_exponent: original.private_exponent, + prime1: original.prime1, + prime2: original.prime2, + exponent1: original.exponent1, + exponent2: original.exponent2, + coefficient: original.coefficient, + other_prime_infos: None, + }; + let pkcs1 = der::Encode::to_der(&oversized).expect("PKCS#1 encodes"); + let octets = der::asn1::OctetStringRef::new(&pkcs1).expect("PKCS#8 octets"); + let pkcs8 = der::Encode::to_der(&PrivateKeyInfoRef::new(rsa::pkcs1::ALGORITHM_ID, octets)) + .expect("PKCS#8 encodes"); + let mut inventory = KeyInventory::default(); + let error = inventory + .add_private_der( + "oversized".into(), + &pkcs8, + None, + KeyUsages::SIGN, + &ResourcePolicy::default(), + ) + .expect_err("oversized RSA modulus must fail at preflight"); + assert!(error.to_string().contains("safety limit"), "{error}"); + + let oversized_exponent = vec![1_u8; 1025]; + let oversized = rsa::pkcs1::RsaPrivateKey { + modulus: original.modulus, + public_exponent: original.public_exponent, + private_exponent: der::asn1::UintRef::new(&oversized_exponent) + .expect("positive exponent"), + prime1: original.prime1, + prime2: original.prime2, + exponent1: original.exponent1, + exponent2: original.exponent2, + coefficient: original.coefficient, + other_prime_infos: None, + }; + let pkcs1 = der::Encode::to_der(&oversized).expect("PKCS#1 encodes"); + let error = preflight_rsa_pkcs1_components(&pkcs1) + .expect_err("oversized private exponent must fail before bigint decoding"); + assert!(error.to_string().contains("safety limit"), "{error}"); + } + + #[test] + fn named_certificate_import_is_not_a_trust_anchor() { + // A certificate is usable as a named public-key source but importing + // it must never grant certificate-chain trust implicitly. + let pem = include_bytes!("../tests/fixtures/keys/rsa/rsa-4096-cert.pem"); + let certificate = + single_pem_block(pem, ResourcePolicy::default().max_external_resource_bytes) + .expect("certificate fixture is one PEM block"); + let mut inventory = KeyInventory::default(); + inventory + .add_public_der( + "recipient-cert".into(), + certificate.contents().to_vec(), + &ResourcePolicy::default(), + ) + .expect("named X.509 certificate imports"); + assert!( + inventory + .rsa_encryption_key( + "recipient-cert", + &crate::policy::EncryptionPolicy::default() + ) + .is_ok() + ); + let restricted = crate::policy::EncryptionPolicy { + resources: ResourcePolicy { + max_external_resource_bytes: 64, + max_external_resource_total_bytes: 64, + ..ResourcePolicy::default() + }, + ..crate::policy::EncryptionPolicy::default() + }; + assert!( + inventory + .rsa_encryption_key("recipient-cert", &restricted) + .is_err() + ); + assert!(inventory.trusted_certificates.is_empty()); + } + + #[test] + fn unsupported_spki_is_rejected_at_import() { + // A syntactically valid Ed25519 SPKI must not acquire VERIFY usage. + let mut ed25519_spki = vec![ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, + ]; + ed25519_spki.extend_from_slice(&[1; 32]); + let mut inventory = KeyInventory::default(); + assert!( + inventory + .add_public_der( + "unsupported".into(), + ed25519_spki, + &ResourcePolicy::default() + ) + .is_err() + ); + assert!(inventory.public_keys.is_empty()); + } + + #[test] + fn named_certificate_resolution_preserves_document_crl() { + // Named inventory selection must preserve document revocation evidence; + // without it the same chain is valid and resolves successfully. + use rcgen::{CertificateParams, KeyPair, KeyUsagePurpose, SerialNumber}; + let mut root_params = CertificateParams::new(Vec::new()).expect("root params"); + root_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + root_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let root = rcgen::CertifiedIssuer::self_signed( + root_params, + KeyPair::generate().expect("root key"), + ) + .expect("root certificate"); + let mut leaf_params = CertificateParams::new(Vec::new()).expect("leaf params"); + leaf_params.serial_number = Some(SerialNumber::from(42_u64)); + leaf_params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + let leaf = leaf_params + .signed_by(&KeyPair::generate().expect("leaf key"), &root) + .expect("leaf certificate"); + let now = time::OffsetDateTime::now_utc(); + let crl = rcgen::CertificateRevocationListParams { + this_update: now - time::Duration::days(1), + next_update: now + time::Duration::days(1), + crl_number: SerialNumber::from(1_u64), + issuing_distribution_point: None, + revoked_certs: vec![rcgen::RevokedCertParams { + serial_number: SerialNumber::from(42_u64), + revocation_time: now - time::Duration::hours(1), + reason_code: None, + invalidity_date: None, + }], + key_identifier_method: rcgen::KeyIdMethod::Sha256, + } + .signed_by(&root) + .expect("signed CRL"); + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_public_der("leaf".into(), leaf.der().to_vec(), &resources) + .expect("leaf imports"); + inventory + .add_certificate_der(root.der().to_vec(), true, &resources) + .expect("root imports"); + let policy = crate::policy::VerificationPolicy { + key_trust: crate::policy::KeyTrustPolicy { + verify_x509_chains: true, + check_crls: true, + verification_time: Some(std::time::SystemTime::now()), + ..crate::policy::KeyTrustPolicy::default() + }, + ..crate::policy::VerificationPolicy::default() + }; + let mut info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("leaf".into())], + }; + let resolver = inventory.verification_resolver(); + assert!( + resolver + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::EcdsaSha256, + &policy, + crate::provider::default_provider() + ) + .expect("unrevoked chain resolves") + .is_some() + ); + info.sources.push(KeyInfoSource::X509Data(X509DataInfo { + crls: vec![crl.der().to_vec()], + ..X509DataInfo::default() + })); + let error = resolver + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::EcdsaSha256, + &policy, + crate::provider::default_provider(), + ) + .err() + .expect("document CRL revokes leaf"); + assert!( + error + .to_string() + .contains("certificate at chain position 0 is revoked"), + "{error}" + ); + let mut bounded = policy.clone(); + bounded.resources.max_external_resource_total_bytes = + leaf.der().len() + root.der().len() + crl.der().len() - 1; + assert!(matches!( + resolver.resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::EcdsaSha256, + &bounded, + crate::provider::default_provider() + ), + Err(DsigError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + .. + } + )) + )); + bounded.key_trust.check_crls = false; + assert!( + resolver + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::EcdsaSha256, + &bounded, + crate::provider::default_provider() + ) + .expect("disabled CRL checks do not load CRLs") + .is_some() + ); + } + + #[test] + fn named_certificate_resolution_enforces_inventory_crl() { + // A KeyName must not drop caller-supplied revocation evidence. + fn cert(pem: &[u8]) -> Vec { + let text = std::str::from_utf8(pem).expect("fixture is UTF-8"); + let start = text.find("-----BEGIN ").expect("fixture has PEM armor"); + single_pem_block( + &text.as_bytes()[start..], + ResourcePolicy::default().max_external_resource_bytes, + ) + .expect("certificate PEM parses") + .into_contents() + } + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_public_der( + "leaf".into(), + cert(include_bytes!( + "../tests/fixtures/keys/rsa/rsa-2048-cert.pem" + )), + &resources, + ) + .expect("leaf imports"); + for anchor in [ + include_bytes!("../tests/fixtures/keys/ca2cert.pem").as_slice(), + include_bytes!("../tests/fixtures/keys/cacert.pem").as_slice(), + ] { + inventory + .add_certificate_der(cert(anchor), true, &resources) + .expect("anchor imports"); + } + inventory + .add_crl_der( + cert(include_bytes!( + "../tests/fixtures/keys/rsa/rsa-2048-cert-revoked-crl.pem" + )), + &resources, + ) + .expect("CRL imports"); + let policy = crate::policy::VerificationPolicy { + key_trust: crate::policy::KeyTrustPolicy { + verify_x509_chains: true, + check_crls: true, + max_x509_chain_depth: 3, + verification_time: Some( + std::time::SystemTime::UNIX_EPOCH + + std::time::Duration::from_secs(1_773_964_800), + ), + ..crate::policy::KeyTrustPolicy::default() + }, + ..crate::policy::VerificationPolicy::default() + }; + let key_info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("leaf".into())], + ..KeyInfo::default() + }; + let error = inventory + .verification_resolver() + .resolve_with_policy_and_provider( + Some(&key_info), + SignatureAlgorithm::RsaSha256, + &policy, + crate::provider::default_provider(), + ) + .err() + .expect("revocation evidence must reject this chain"); + assert!(error.to_string().contains("cRLSign"), "{error}"); + } + + #[test] + fn hmac_resolution_does_not_load_unrelated_certificate_material() { + // The active snapshot bounds selected material, not unrelated X.509 + // bytes that an HMAC resolver never needs to copy or inspect. + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "hmac".into(), + SymmetricKeyKind::Hmac, + b"secret".to_vec(), + KeyUsages::VERIFY, + &ResourcePolicy::default(), + ) + .expect("HMAC imports"); + let pem = include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-cert.pem"); + let certificate = + single_pem_block(pem, ResourcePolicy::default().max_external_resource_bytes) + .expect("certificate fixture"); + inventory + .add_certificate_der( + certificate.contents().to_vec(), + true, + &ResourcePolicy::default(), + ) + .expect("anchor imports"); + let policy = crate::policy::VerificationPolicy { + resources: ResourcePolicy { + max_external_resource_bytes: 64, + max_external_resource_total_bytes: 64, + ..ResourcePolicy::default() + }, + ..crate::policy::VerificationPolicy::default() + }; + let info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("hmac".into())], + ..KeyInfo::default() + }; + assert!( + inventory + .verification_resolver() + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::HmacSha256, + &policy, + crate::provider::default_provider(), + ) + .expect("unrelated certificates cannot consume HMAC budget") + .is_some() + ); + } + + #[test] + fn named_key_resolution_obeys_source_policy() { + // Inventory lookup is not permission to use a KeyName source that the + // operation's immutable verification policy has disabled. + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "blocked-name".into(), + SymmetricKeyKind::Hmac, + b"policy-guarded-hmac-secret".to_vec(), + KeyUsages::VERIFY, + &resources, + ) + .expect("HMAC fixture imports"); + let info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("blocked-name".into())], + ..KeyInfo::default() + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.key_sources.key_name = false; + assert!( + inventory + .verification_resolver() + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::HmacSha256, + &policy, + crate::provider::default_provider(), + ) + .is_err() + ); + } + + #[test] + fn named_lookup_and_fallback_share_one_candidate_budget() { + // Finding a named inventory entry and resolving its embedded KeyValue + // are one verification operation, not two independently budgeted scans. + let mut inventory = KeyInventory::default(); + inventory.public_keys.push(StoredPublicKey { + name: "named".into(), + key_info: KeyInfo { + sources: vec![ + KeyInfoSource::KeyName("named".into()), + KeyInfoSource::KeyValue(KeyValueInfo::Rsa { + modulus: vec![3], + exponent: vec![3], + }), + ], + }, + usages: KeyUsages::VERIFY, + }); + let info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("named".into())], + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.resources.max_key_candidates = 1; + let error = inventory + .verification_resolver() + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::RsaSha256, + &policy, + crate::provider::default_provider(), + ) + .err() + .expect("delegation must not reset the candidate budget"); + assert!(matches!( + error, + DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::KEY_CANDIDATES, + maximum: 1, + actual: 2, + }) + )); + } + + #[test] + fn prefix_retry_spends_the_same_candidate_budget() { + // Unresolved sources preceding X.509 lookup are visited twice, so both + // passes must charge the same operation budget. + let mut sources = vec![KeyInfoSource::KeyName("missing".into()); 3]; + sources.push(KeyInfoSource::X509Data(X509DataInfo { + subject_names: vec!["missing subject".into()], + ..X509DataInfo::default() + })); + let info = KeyInfo { sources }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.resources.max_key_candidates = 5; + let error = KeyInventory::default() + .verification_resolver() + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::RsaSha256, + &policy, + crate::provider::default_provider(), + ) + .err() + .expect("the repeated prefix must exhaust the candidate limit"); + assert!(matches!( + error, + DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::KEY_CANDIDATES, + maximum: 5, + actual: 6, + }) + )); + } + + #[test] + fn selected_key_value_is_one_resource() { + // Representation must not split one key into separately bounded + // components; exact boundaries remain accepted for all KeyValue kinds. + for value in [ + KeyValueInfo::Rsa { + modulus: vec![1; 256], + exponent: vec![1; 3], + }, + KeyValueInfo::Dsa { + p: Some(vec![1; 64]), + q: Some(vec![1; 16]), + g: Some(vec![1; 64]), + y: vec![1; 64], + }, + KeyValueInfo::Ec { + curve_oid: "1.2.840.10045.3.1.7".into(), + public_key: vec![1; 65], + }, + ] { + let info = KeyInfo { + sources: vec![KeyInfoSource::KeyValue(value)], + }; + let size = + check_selected_public_material(&info, &ResourcePolicy::default()).expect("size"); + let resources = ResourcePolicy { + max_external_resource_bytes: size, + ..ResourcePolicy::default() + }; + assert_eq!( + check_selected_public_material(&info, &resources).expect("exact limit"), + size + ); + let resources = ResourcePolicy { + max_external_resource_bytes: size - 1, + ..resources + }; + assert!(matches!(check_selected_public_material(&info, &resources), + Err(DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, actual, .. + })) if actual == size)); + } + } + + #[test] + fn named_verification_bounds_complete_key_value() { + // A broadly imported XML key must obey the tighter operation snapshot + // before the resolver constructs an SPKI from its components. + use rsa::{pkcs8::DecodePublicKey as _, traits::PublicKeyParts as _}; + let public = RsaPublicKey::from_public_key_pem(include_str!( + "../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem" + )) + .expect("RSA fixture"); + let modulus = public.n().to_be_bytes_trimmed_vartime(); + let exponent = public.e().to_be_bytes_trimmed_vartime(); + let size = modulus.len() + exponent.len(); + let base64 = base64::engine::general_purpose::STANDARD; + let xml = format!( + "named{}{}", + base64.encode(modulus), + base64.encode(exponent) + ); + let inventory = KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(ResourcePolicy::default()), + XmlBackend::default(), + ) + .expect("broad import"); + let info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("named".into())], + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.resources.max_external_resource_bytes = size; + assert!( + inventory + .verification_resolver() + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::RsaSha256, + &policy, + crate::provider::default_provider() + ) + .expect("exact complete-key limit") + .is_some() + ); + policy.resources.max_external_resource_bytes = size - 1; + assert!( + matches!(inventory.verification_resolver().resolve_with_policy_and_provider(Some(&info), SignatureAlgorithm::RsaSha256, + &policy, crate::provider::default_provider()), + Err(DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, actual, .. + })) if actual == size) + ); + } + + #[test] + fn selected_certificate_and_anchors_share_aggregate_budget() { + // Selected named material and configured trust material are one + // operation, even though they enter the resolver through separate paths. + let certificate = single_pem_block( + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-cert.pem"), + ResourcePolicy::default().max_external_resource_bytes, + ) + .expect("leaf PEM") + .into_contents(); + let anchor = single_pem_block( + include_bytes!("../tests/fixtures/keys/rsa/rsa-4096-cert.pem"), + ResourcePolicy::default().max_external_resource_bytes, + ) + .expect("anchor PEM") + .into_contents(); + let mut inventory = KeyInventory::default(); + inventory + .add_public_der( + "leaf".into(), + certificate.clone(), + &ResourcePolicy::default(), + ) + .expect("leaf imports"); + inventory + .add_certificate_der(anchor.clone(), true, &ResourcePolicy::default()) + .expect("anchor imports"); + let mut policy = crate::policy::VerificationPolicy::default(); + policy.key_trust.verify_x509_chains = true; + policy.resources.max_external_resource_total_bytes = certificate.len() + anchor.len() - 1; + let info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("leaf".into())], + }; + let error = inventory + .verification_resolver() + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::RsaSha256, + &policy, + crate::provider::default_provider(), + ) + .err() + .expect("combined selected and configured material exceeds the budget"); + assert!(matches!( + error, + DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + .. + }) + )); + } + + #[test] + fn named_key_does_not_bypass_other_source_permissions() { + // Every source in a document KeyInfo is subject to the policy, even + // when the inventory can resolve its KeyName without the other source. + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "named".into(), + SymmetricKeyKind::Hmac, + b"verification-secret".to_vec(), + KeyUsages::VERIFY, + &resources, + ) + .expect("HMAC fixture imports"); + let info = KeyInfo { + sources: vec![ + KeyInfoSource::KeyName("named".into()), + KeyInfoSource::KeyValue(KeyValueInfo::Rsa { + modulus: vec![3], + exponent: vec![3], + }), + ], + ..KeyInfo::default() + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.key_sources.key_value = false; + assert!( + inventory + .verification_resolver() + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::HmacSha256, + &policy, + crate::provider::default_provider(), + ) + .is_err() + ); + } + + #[test] + fn named_public_key_is_trusted_inventory_material() { + // A document KeyName must not inherit the source restrictions of the + // caller-owned public key's internal representation. + let mut inventory = KeyInventory::default(); + inventory + .add_public_pem( + "named".into(), + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"), + &ResourcePolicy::default(), + ) + .expect("public key imports"); + let info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("named".into())], + ..KeyInfo::default() + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.key_sources.key_value = false; + policy.key_sources.der_encoded_key_value = false; + policy.key_sources.x509_data = false; + assert!( + inventory + .verification_resolver() + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::RsaSha256, + &policy, + crate::provider::default_provider(), + ) + .expect("trusted inventory material resolves") + .is_some() + ); + } + + #[test] + fn named_public_key_obeys_current_verification_limits() { + // A permissive import policy cannot replace a later stricter operation snapshot. + let mut inventory = KeyInventory::default(); + inventory + .add_public_pem( + "named".into(), + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"), + &ResourcePolicy::default(), + ) + .expect("public key imports"); + let info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("named".into())], + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.resources.max_external_resource_bytes = 1; + assert!( + inventory + .verification_resolver() + .resolve_with_policy(Some(&info), SignatureAlgorithm::RsaSha256, &policy) + .is_err() + ); + let certificate = pem::parse(include_bytes!( + "../tests/fixtures/keys/rsa/rsa-2048-cert.pem" + )) + .expect("certificate PEM") + .into_contents(); + let mut certificate_inventory = KeyInventory::default(); + certificate_inventory + .add_public_der("named".into(), certificate, &ResourcePolicy::default()) + .expect("certificate imports as a named public key"); + assert!( + certificate_inventory + .verification_resolver() + .resolve_with_policy(Some(&info), SignatureAlgorithm::RsaSha256, &policy) + .is_err() + ); + } + + #[test] + fn unused_certificates_do_not_block_earlier_public_keys() { + // X.509 lookup bytes are irrelevant when a preceding DER key resolves. + let mut inventory = KeyInventory::default(); + let certificate = pem::parse(include_bytes!( + "../tests/fixtures/keys/rsa/rsa-2048-cert.pem" + )) + .expect("certificate PEM") + .into_contents(); + inventory + .add_certificate_der(certificate, false, &ResourcePolicy::default()) + .expect("certificate imports"); + let public = pem::parse(include_bytes!( + "../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem" + )) + .expect("public PEM") + .into_contents(); + let info = KeyInfo { + sources: vec![ + KeyInfoSource::DerEncodedKeyValue(public.clone()), + KeyInfoSource::X509Data(X509DataInfo { + subject_names: vec!["unused".into()], + ..X509DataInfo::default() + }), + ], + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.resources.max_external_resource_bytes = public.len(); + assert!( + inventory + .verification_resolver() + .resolve_with_policy(Some(&info), SignatureAlgorithm::RsaSha256, &policy) + .expect("earlier public key resolves") + .is_some() + ); + } + + #[test] + fn public_import_rejects_incompatible_usage() { + // Public material may verify or encrypt, but cannot authorize signing. + let resources = ResourcePolicy::default(); + let public = include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"); + let mut inventory = KeyInventory::default(); + assert!( + inventory + .add_public_pem_with_usages("invalid".into(), public, KeyUsages::SIGN, &resources,) + .is_err() + ); + assert_eq!(inventory.entry_count(), 0); + } + + #[test] + fn public_certificate_import_rejects_unsupported_key_family() { + // A syntactically valid certificate cannot advertise verification when + // none of the supported XMLDSig verifiers can consume its public key. + let pair = + rcgen::KeyPair::generate_for(&rcgen::PKCS_ED25519).expect("Ed25519 key generation"); + let params = rcgen::CertificateParams::new(vec!["example.test".into()]) + .expect("certificate parameters"); + let certificate = params.self_signed(&pair).expect("certificate generation"); + assert!( + KeyInventory::default() + .add_public_der( + "unsupported".into(), + certificate.der().to_vec(), + &ResourcePolicy::default() + ) + .is_err() + ); + } + + #[cfg(feature = "xmlenc")] + #[test] + fn imported_usage_restricts_operation_selection() { + // Explicit restrictions survive import and are enforced when selecting + // material for the opposite operation. + let resources = ResourcePolicy::default(); + let public = include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"); + let bundle = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + let mut inventory = KeyInventory::default(); + inventory + .add_public_pem_with_usages("verify".into(), public, KeyUsages::VERIFY, &resources) + .expect("verification-only public key imports"); + assert!( + inventory + .rsa_encryption_key("verify", &crate::policy::EncryptionPolicy::default()) + .is_err() + ); + inventory + .add_pkcs12_with_usages( + "decrypt".into(), + bundle, + "secret", + KeyUsages::DECRYPT, + &resources, + ) + .expect("decryption-only private key imports"); + assert!( + inventory + .signing_key( + "decrypt", + SignatureAlgorithm::RsaSha256, + &crate::policy::SigningPolicy::default(), + ) + .is_err() + ); + assert!( + inventory + .decryption_resolver("decrypt", &crate::policy::DecryptionPolicy::default()) + .is_ok() + ); + } + + #[cfg(feature = "xmlenc")] + #[test] + fn selected_rsa_recipient_obeys_operation_modulus_policy() { + // Import permission does not override a stricter encryption snapshot. + let mut inventory = KeyInventory::default(); + inventory + .add_public_pem( + "rsa".into(), + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"), + &ResourcePolicy::default(), + ) + .expect("RSA fixture imports"); + let mut policy = crate::policy::EncryptionPolicy::default(); + policy.rsa_keys.minimum_modulus_bits = 4096; + assert!(matches!( + inventory.rsa_encryption_key("rsa", &policy), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::KeySize { .. } + )) + )); + assert!(matches!( + inventory.public_keys()[0].rsa_encryption_key(&policy), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::KeySize { .. } + )) + )); + } + + #[test] + fn inventory_merge_checks_names_and_budget_atomically() { + // Combining independently parsed stores cannot bypass name or + // aggregate-material limits, and a rejected merge is atomic. + let resources = ResourcePolicy::default(); + let mut first = KeyInventory::default(); + first + .add_symmetric( + "one".into(), + SymmetricKeyKind::Hmac, + b"first-secret".to_vec(), + KeyUsages::SIGN, + &resources, + ) + .expect("first key imports"); + let mut duplicate = KeyInventory::default(); + duplicate + .add_symmetric( + "one".into(), + SymmetricKeyKind::Hmac, + b"second-secret".to_vec(), + KeyUsages::VERIFY, + &resources, + ) + .expect("independent duplicate imports"); + assert!(matches!( + first.extend(duplicate, &resources), + Err(KeyStoreError::Selection("duplicate key name")) + )); + assert_eq!(first.entry_count(), 1); + let mut second = KeyInventory::default(); + second + .add_symmetric( + "two".into(), + SymmetricKeyKind::Hmac, + b"second-secret".to_vec(), + KeyUsages::VERIFY, + &resources, + ) + .expect("second key imports"); + let constrained = ResourcePolicy { + max_external_resource_total_bytes: b"first-secret".len(), + ..ResourcePolicy::default() + }; + assert!(matches!( + first.extend(second, &constrained), + Err(KeyStoreError::Selection( + "key material total exceeds resource limit" + )) + )); + assert_eq!(first.entry_count(), 1); + } + + #[test] + fn public_dsa_store_entries_require_usable_parameters() { + // The inventory has no implicit parameter inheritance. Do not grant + // VERIFY to material that its own resolver cannot construct as a key. + for fields in [ + "AQ==", + "

AQ==", + "

AQ==

AQ==AQ==AQ==", + ] { + let xml = format!( + "dsa{fields}" + ); + assert!( + KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(ResourcePolicy::default()), + XmlBackend::default() + ) + .is_err(), + "accepted unusable DSA: {fields}" + ); + } + } + + #[test] + fn oversized_private_dsa_component_stops_before_big_integer_work() { + // A bounded XML file can still contain an outsized exponent; reject + // it before constructing a large modular exponentiation. + let oversized = base64::engine::general_purpose::STANDARD.encode(vec![1_u8; 513]); + let xml = format!( + "dsa

{oversized}

AQ==AQ==AQ==AQ==
" + ); + assert!(matches!( + KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(ResourcePolicy::default()), + XmlBackend::default(), + ), + Err(KeyStoreError::Invalid(message)) if message.contains("safety limit") + )); + } + + #[test] + fn oversized_pkcs8_dsa_parameters_stop_before_key_derivation() { + // PKCS#8 uses the same component ceiling as xmlsec's DSAKeyValue. + #[derive(der::Sequence)] + struct DsaParameters<'a> { + p: der::asn1::UintRef<'a>, + q: der::asn1::UintRef<'a>, + g: der::asn1::UintRef<'a>, + } + let oversized = vec![1_u8; crate::hard_limits::DSA_KEY_COMPONENT_BYTE_CEILING + 1]; + let one = [1_u8]; + let parameters = der::Encode::to_der(&DsaParameters { + p: der::asn1::UintRef::new(&oversized).expect("positive P"), + q: der::asn1::UintRef::new(&one).expect("positive Q"), + g: der::asn1::UintRef::new(&one).expect("positive G"), + }) + .expect("DSA parameters encode"); + let x = der::Encode::to_der(&der::asn1::UintRef::new(&one).expect("positive X")) + .expect("DSA X encodes"); + let algorithm = rsa::pkcs8::AlgorithmIdentifierRef { + oid: dsa::OID, + parameters: Some(der::asn1::AnyRef::from_der(¶meters).expect("parameters")), + }; + let private = der::asn1::OctetStringRef::new(&x).expect("private octets"); + let pkcs8 = der::Encode::to_der(&PrivateKeyInfoRef::new(algorithm, private)) + .expect("PKCS#8 encodes"); + let mut inventory = KeyInventory::default(); + let error = inventory + .add_private_der( + "oversized-dsa".into(), + &pkcs8, + None, + KeyUsages::SIGN, + &ResourcePolicy::default(), + ) + .expect_err("oversized DSA parameter must fail preflight"); + assert!(error.to_string().contains("safety limit"), "{error}"); + } + + #[test] + fn compressed_ec_spki_cannot_acquire_verify_usage() { + // Import must enforce the same SEC1 profile as signature verification, + // for every supported curve, rather than grant unusable VERIFY usage. + for pem in [ + include_bytes!("../tests/fixtures/keys/ec/ec-prime256v1-pubkey.pem").as_slice(), + include_bytes!("../tests/fixtures/keys/ec/ec-prime384v1-pubkey.pem").as_slice(), + include_bytes!("../tests/fixtures/keys/ec/ec-prime521v1-pubkey.pem").as_slice(), + ] { + let original = + single_pem_block(pem, ResourcePolicy::default().max_external_resource_bytes) + .expect("EC fixture") + .into_contents(); + let spki = rsa::pkcs8::SubjectPublicKeyInfoRef::from_der(&original).expect("SPKI"); + let point = spki + .subject_public_key + .as_bytes() + .expect("octet-aligned point"); + let coordinate_len = (point.len() - 1) / 2; + let mut compressed = vec![2 | (point.last().expect("Y coordinate") & 1)]; + compressed.extend_from_slice(&point[1..=coordinate_len]); + let encoded = der::Encode::to_der(&rsa::pkcs8::SubjectPublicKeyInfoRef { + algorithm: spki.algorithm, + subject_public_key: der::asn1::BitStringRef::from_bytes(&compressed) + .expect("point"), + }) + .expect("compressed SPKI"); + let mut inventory = KeyInventory::default(); + assert!( + inventory + .add_public_der("compressed".into(), encoded, &ResourcePolicy::default()) + .is_err() + ); + assert!(inventory.public_keys().is_empty()); + inventory + .add_public_der("uncompressed".into(), original, &ResourcePolicy::default()) + .expect("supported uncompressed encoding remains usable"); + } + // A genuinely signed certificate wrapper must not bypass this profile. + struct CompressedPoint<'a>(&'a [u8], &'static rcgen::SignatureAlgorithm); + impl rcgen::PublicKeyData for CompressedPoint<'_> { + fn der_bytes(&self) -> &[u8] { + self.0 + } + fn algorithm(&self) -> &'static rcgen::SignatureAlgorithm { + self.1 + } + } + let mut root_params = rcgen::CertificateParams::new(Vec::new()).expect("root params"); + root_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + let root = rcgen::CertifiedIssuer::self_signed( + root_params, + rcgen::KeyPair::generate().expect("root key"), + ) + .expect("root certificate"); + for (pem, algorithm) in [ + ( + include_bytes!("../tests/fixtures/keys/ec/ec-prime256v1-cert.pem").as_slice(), + &rcgen::PKCS_ECDSA_P256_SHA256, + ), + ( + include_bytes!("../tests/fixtures/keys/ec/ec-prime384v1-cert.pem").as_slice(), + &rcgen::PKCS_ECDSA_P384_SHA384, + ), + ] { + let original = + single_pem_block(pem, ResourcePolicy::default().max_external_resource_bytes) + .expect("certificate fixture") + .into_contents(); + let (_, certificate) = X509Certificate::from_der(&original).expect("certificate"); + let point = certificate.public_key().subject_public_key.data.as_ref(); + let coordinate_len = (point.len() - 1) / 2; + let mut compressed = vec![2 | (point.last().expect("Y coordinate") & 1)]; + compressed.extend_from_slice(&point[1..=coordinate_len]); + let leaf = rcgen::CertificateParams::new(Vec::new()) + .expect("leaf params") + .signed_by(&CompressedPoint(&compressed, algorithm), &root) + .expect("signed compressed certificate"); + let encoded = leaf.der().to_vec(); + let mut inventory = KeyInventory::default(); + assert!( + inventory + .add_public_der( + "compressed-cert".into(), + encoded, + &ResourcePolicy::default() + ) + .is_err() + ); + assert!(inventory.public_keys().is_empty()); + inventory + .add_public_der( + "uncompressed-cert".into(), + original, + &ResourcePolicy::default(), + ) + .expect("uncompressed certificate imports"); + } + } + + #[test] + fn malformed_ec_point_cannot_acquire_verify_usage() { + // A supported curve OID does not make an off-curve point usable. + let block = single_pem_block( + include_bytes!("../tests/fixtures/keys/ec/ec-prime256v1-pubkey.pem"), + ResourcePolicy::default().max_external_resource_bytes, + ) + .expect("EC SPKI fixture"); + let mut der = block.into_contents(); + let last = der.last_mut().expect("point bytes"); + *last ^= 1; + let mut inventory = KeyInventory::default(); + assert!( + inventory + .add_public_der("off-curve".into(), der, &ResourcePolicy::default()) + .is_err() + ); + assert!(inventory.public_keys().is_empty()); + } + + #[cfg(feature = "xmlenc")] + #[test] + fn decryption_policy_rejection_retains_its_type() { + // An invalid operation snapshot is not a candidate-local key miss. + let mut policy = crate::policy::DecryptionPolicy::default(); + policy.resources.max_external_resource_bytes = usize::MAX; + assert!(matches!( + KeyInventory::default().decryption_resolver("missing", &policy), + Err(KeyStoreError::Policy(_)) + )); + } + + #[cfg(feature = "xmlenc")] + #[test] + fn inventory_decryption_resolver_enforces_aes_usage_end_to_end() { + // Inventory authorization is checked before the normal XMLEnc + // decryptor receives an otherwise valid direct AES content key. + use crate::xmlenc::{ + DataEncryptionAlgorithm, DecryptContext, DecryptedContent, EncryptedDataBuilder, + }; + + let key = b"0123456789abcdef"; + let encrypted = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .direct_key(*key) + .encrypt_binary(b"inventory decrypt payload") + .expect("AES fixture encrypts"); + let resources = ResourcePolicy::default(); + let mut keys = KeyInventory::default(); + keys.add_symmetric( + "decrypt".into(), + SymmetricKeyKind::Aes, + key.to_vec(), + KeyUsages::DECRYPT, + &resources, + ) + .expect("decrypt key imports"); + let resolver = keys + .decryption_resolver("decrypt", &crate::policy::DecryptionPolicy::default()) + .expect("decrypt use is allowed"); + let content = DecryptContext::new(resolver.as_ref()) + .decrypt(&encrypted.encrypted_data_xml) + .expect("inventory key decrypts"); + assert!( + matches!(content, DecryptedContent::Bytes(bytes) if bytes == b"inventory decrypt payload") + ); + + let mut restricted = KeyInventory::default(); + restricted + .add_symmetric( + "encrypt-only".into(), + SymmetricKeyKind::Aes, + key.to_vec(), + KeyUsages::ENCRYPT, + &resources, + ) + .expect("encrypt-only key imports"); + assert!( + restricted + .decryption_resolver("encrypt-only", &crate::policy::DecryptionPolicy::default()) + .is_err() + ); + } + + #[cfg(feature = "xmlenc")] + #[test] + fn inventory_direct_aes_does_not_consume_recipient_candidates() { + // A direct AES key is not a wrapping key. Recipient traversal must + // leave its sole candidate available for the later direct-key path. + use crate::xmlenc::{ + CipherData, DataEncryptionAlgorithm, EncryptedKey, EncryptionMethod, + KeyCandidateBudget, KeyTransportAlgorithm, XmlEncError, + }; + let mut keys = KeyInventory::default(); + keys.add_symmetric( + "direct".into(), + SymmetricKeyKind::Aes, + vec![1; 16], + KeyUsages::DECRYPT, + &ResourcePolicy::default(), + ) + .expect("AES imports"); + let resolver = keys + .decryption_resolver("direct", &crate::policy::DecryptionPolicy::default()) + .expect("AES resolver"); + let recipient = EncryptedKey { + id: None, + recipient: None, + key_name: None, + encryption_method: EncryptionMethod { + algorithm: KeyTransportAlgorithm::RsaOaep11.uri().into(), + key_size_bits: None, + oaep_digest: None, + mgf_algorithm: None, + oaep_params: None, + }, + cipher_data: CipherData { + value: String::new(), + }, + reference_list: None, + carried_key_name: None, + }; + let mut budget = KeyCandidateBudget::with_limit(1); + let provider = crate::provider::RustCryptoProvider; + for _ in 0..64 { + assert!(matches!( + resolver.resolve_key_candidates( + &provider, + DataEncryptionAlgorithm::Aes128Gcm, + Some(&recipient), + &mut budget + ), + Err(XmlEncError::KeyNotFound) + )); + assert_eq!(budget.remaining(), 1); + } + assert_eq!( + resolver + .resolve_key_candidates( + &provider, + DataEncryptionAlgorithm::Aes128Gcm, + None, + &mut budget + ) + .expect("one direct candidate"), + vec![vec![1; 16]] + ); + assert_eq!(budget.remaining(), 0); + } + + #[cfg(feature = "xmlenc")] + #[test] + fn decryption_selection_checks_operation_limits_before_material_use() { + // Reusing a broadly imported inventory with a tighter operation + // snapshot must reject both AES and RSA material before copy/decode. + let resources = ResourcePolicy::default(); + let mut keys = KeyInventory::default(); + keys.add_symmetric( + "aes".into(), + SymmetricKeyKind::Aes, + vec![0x31; 16], + KeyUsages::DECRYPT, + &resources, + ) + .expect("AES key imports"); + keys.add_private_pem( + "rsa".into(), + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-key.pem"), + None, + KeyUsages::DECRYPT, + &resources, + ) + .expect("RSA key imports"); + let mut policy = crate::policy::DecryptionPolicy::default(); + policy.resources.max_external_resource_bytes = 8; + policy.resources.max_external_resource_total_bytes = 8; + assert!(keys.decryption_resolver("aes", &policy).is_err()); + assert!(keys.decryption_resolver("rsa", &policy).is_err()); + } +} diff --git a/src/key_manager/pkcs12_import.rs b/src/key_manager/pkcs12_import.rs new file mode 100644 index 00000000..14184f33 --- /dev/null +++ b/src/key_manager/pkcs12_import.rs @@ -0,0 +1,1201 @@ +//! Borrowed BER import orchestration; RustCrypto supplies cryptographic primitives. + +use aes::cipher::{BlockModeDecrypt, KeyIvInit, block_padding::Pkcs7}; +use core::ops::Deref; +use der::asn1::ObjectIdentifier as Oid; +use hmac::{Hmac, KeyInit as _, Mac as _}; +use pkcs12::kdf::{Pkcs12KeyType, derive_key}; +use zeroize::Zeroizing; + +use super::KeyStoreError; +use crate::policy::{PolicyViolation, ResourcePolicy, resource_name}; + +type Result = core::result::Result; +const DATA: Oid = Oid::new_unwrap("1.2.840.113549.1.7.1"); +const ENCRYPTED_DATA: Oid = Oid::new_unwrap("1.2.840.113549.1.7.6"); +const PBES2: Oid = Oid::new_unwrap("1.2.840.113549.1.5.13"); +const PBKDF2: Oid = Oid::new_unwrap("1.2.840.113549.1.5.12"); + +pub(super) struct Limits { + pub resources: ResourcePolicy, + pub candidates: usize, + pub memory_available: usize, +} + +pub(super) struct Contents { + pub private_keys: Vec>>, + pub certificates: Vec>, +} + +struct Budget<'a> { + limits: &'a Limits, + work: usize, + memory: usize, + bags: usize, + infos: usize, +} + +fn denial(resource: &'static str, maximum: usize) -> KeyStoreError { + PolicyViolation::ResourceLimitExceeded { resource, maximum }.into() +} + +fn malformed() -> Result { + Err(KeyStoreError::ProtectedContainer) +} + +impl<'a> Budget<'a> { + fn new(limits: &'a Limits) -> Self { + Self { + limits, + work: 0, + memory: 0, + bags: 0, + infos: 0, + } + } + + fn allocate(&mut self, size: usize) -> Result<()> { + let maximum = self.limits.resources.max_external_resource_total_bytes; + if size > self.limits.memory_available - self.memory { + return Err(denial( + resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + maximum, + )); + } + self.memory += size; + Ok(()) + } + + fn copy(&mut self, bytes: &[u8]) -> Result>> { + self.allocate(bytes.len())?; + Ok(Zeroizing::new(bytes.to_vec())) + } + + fn count(&mut self, bag: bool) -> Result<()> { + let count = if bag { &mut self.bags } else { &mut self.infos }; + if *count >= self.limits.candidates { + return Err(denial( + resource_name::KEY_CANDIDATES, + self.limits.candidates, + )); + } + *count += 1; + Ok(()) + } + + fn kdf(&mut self, rounds: u32, blocks: usize, salt: &[u8]) -> Result<()> { + let maximum = self.limits.resources.max_key_import_kdf_work; + if rounds == 0 || u64::from(rounds) > maximum as u64 || rounds > i32::MAX as u32 { + return Err(PolicyViolation::KdfIterationsOutsideLimit { maximum }.into()); + } + let salt_maximum = self.limits.resources.max_external_resource_bytes; + if salt.len() > salt_maximum { + return Err(denial("PKCS#12 salt bytes", salt_maximum)); + } + let work = (rounds as usize) + .checked_mul(blocks) + .ok_or_else(|| denial(resource_name::KEY_IMPORT_KDF_WORK, maximum))?; + if work > maximum - self.work { + return Err(denial(resource_name::KEY_IMPORT_KDF_WORK, maximum)); + } + self.work += work; + Ok(()) + } + + fn legacy_workspace( + &self, + salt: &[u8], + password: &[u8], + block: usize, + output: usize, + ) -> Result<()> { + // RFC 7292 B.2 rounds salt and password up to digest blocks. Account + // for the KDF's I, diversifier and output before RustCrypto allocates. + // https://www.rfc-editor.org/rfc/rfc7292#appendix-B.2 + let size = salt + .len() + .div_ceil(block) + .checked_add(password.len().div_ceil(block)) + .and_then(|n| n.checked_mul(block)) + .and_then(|n| n.checked_add(block + output)) + .ok_or_else(|| { + denial( + resource_name::KEY_IMPORT_KDF_MEMORY, + self.limits.resources.max_key_import_kdf_memory_bytes, + ) + })?; + if size > self.limits.resources.max_key_import_kdf_memory_bytes { + return Err(denial( + resource_name::KEY_IMPORT_KDF_MEMORY, + self.limits.resources.max_key_import_kdf_memory_bytes, + )); + } + if size > self.limits.memory_available - self.memory { + return Err(denial( + resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + self.limits.resources.max_external_resource_total_bytes, + )); + } + Ok(()) + } +} + +/// A TLV view never creates an ASN.1 object tree. Indefinite BER is accepted +/// as required by RFC 7292 4.1; recursion has an absolute stack-safety ceiling. +#[derive(Clone, Copy)] +struct Tlv<'a> { + tag: u8, + value: &'a [u8], +} + +fn tlv(bytes: &[u8], depth: usize) -> Result<(Tlv<'_>, &[u8])> { + if depth >= crate::hard_limits::PKCS12_NESTING_CEILING || bytes.len() < 2 { + return malformed(); + } + let tag = bytes[0]; + if tag & 0x1f == 0x1f || tag == 0 { + return malformed(); + } + let mut start = 2; + let end; + let consumed; + if bytes[1] == 0x80 { + if tag & 0x20 == 0 { + return malformed(); + } + let mut remaining = &bytes[start..]; + loop { + if remaining.starts_with(&[0, 0]) { + end = bytes.len() - remaining.len(); + consumed = end + 2; + break; + } + remaining = tlv(remaining, depth + 1)?.1; + } + } else { + let mut length = usize::from(bytes[1]); + if length & 0x80 != 0 { + let count = length & 0x7f; + if count == 0 || count > core::mem::size_of::() || count > bytes.len() - start { + return malformed(); + } + length = 0; + for byte in &bytes[start..start + count] { + length = length + .checked_mul(256) + .and_then(|v| v.checked_add(usize::from(*byte))) + .ok_or(KeyStoreError::ProtectedContainer)?; + } + start += count; + } + if length > bytes.len() - start { + return malformed(); + } + end = start + length; + consumed = end; + } + Ok(( + Tlv { + tag, + value: &bytes[start..end], + }, + &bytes[consumed..], + )) +} + +struct Reader<'a>(&'a [u8]); +impl<'a> Reader<'a> { + fn take(&mut self, tag: u8) -> Result> { + let (value, rest) = tlv(self.0, 0)?; + if value.tag != tag { + return malformed(); + } + self.0 = rest; + Ok(value) + } + fn sequence(bytes: &'a [u8]) -> Result { + let mut outer = Self(bytes); + let sequence = outer.take(0x30)?; + outer.finish()?; + Ok(Self(sequence.value)) + } + fn finish(self) -> Result<()> { + if self.0.is_empty() { + Ok(()) + } else { + malformed() + } + } + fn oid(&mut self) -> Result { + Oid::from_bytes(self.take(6)?.value).map_err(|_| KeyStoreError::ProtectedContainer) + } + fn integer(&mut self) -> Result { + let bytes = self.take(2)?.value; + // X.690 8.3.2 forbids redundant sign octets in BER INTEGER too, + // not only DER; all these fields require nonnegative values. + // https://www.itu.int/rec/T-REC-X.690-202102-I/en + if bytes.is_empty() + || bytes[0] & 0x80 != 0 + || (bytes.len() > 1 && bytes[0] == 0 && bytes[1] & 0x80 == 0) + { + return malformed(); + } + bytes + .iter() + .try_fold(0_u32, |n, b| { + n.checked_mul(256) + .and_then(|n| n.checked_add(u32::from(*b))) + }) + .ok_or(KeyStoreError::ProtectedContainer) + } + fn null_or_absent(&mut self) -> Result<()> { + if !self.0.is_empty() && !self.take(5)?.value.is_empty() { + return malformed(); + } + Self(self.0).finish() + } +} + +enum Bytes<'a> { + Borrowed(&'a [u8]), + Owned(Zeroizing>), +} +impl Deref for Bytes<'_> { + type Target = [u8]; + fn deref(&self) -> &[u8] { + match self { + Self::Borrowed(v) => v, + Self::Owned(v) => v, + } + } +} +impl Bytes<'_> { + fn owned_capacity(&self) -> usize { + match self { + Self::Borrowed(_) => 0, + Self::Owned(v) => v.capacity(), + } + } + fn release(&self, budget: &mut Budget<'_>) { + if let Self::Owned(v) = self { + budget.memory -= v.capacity(); + } + } +} + +fn octet_visit(value: Tlv<'_>, primitive: u8, depth: usize, visit: &mut F) -> Result<()> +where + F: FnMut(&[u8]) -> Result<()>, +{ + if depth >= crate::hard_limits::PKCS12_NESTING_CEILING { + return malformed(); + } + if value.tag == primitive { + return visit(value.value); + } + if value.tag != primitive | 0x20 { + return malformed(); + } + let mut children = value.value; + while !children.is_empty() { + let (child, rest) = tlv(children, depth)?; + // Constructed implicit [0] OCTET STRING has universal OCTET children. + octet_visit(child, 4, depth + 1, visit)?; + children = rest; + } + Ok(()) +} + +fn octets<'a>(value: Tlv<'a>, primitive: u8, budget: &mut Budget<'_>) -> Result> { + if value.tag == primitive { + return Ok(Bytes::Borrowed(value.value)); + } + let mut size = 0_usize; + octet_visit(value, primitive, 0, &mut |bytes| { + size = size + .checked_add(bytes.len()) + .ok_or(KeyStoreError::ProtectedContainer)?; + Ok(()) + })?; + budget.allocate(size)?; + let mut output = Zeroizing::new(Vec::with_capacity(size)); + octet_visit(value, primitive, 0, &mut |bytes| { + output.extend_from_slice(bytes); + Ok(()) + })?; + Ok(Bytes::Owned(output)) +} + +#[derive(Clone, Copy)] +enum Hash { + Sha1, + Sha224, + Sha256, + Sha384, + Sha512, +} +impl Hash { + fn size(self) -> usize { + match self { + Self::Sha1 => 20, + Self::Sha224 => 28, + Self::Sha256 => 32, + Self::Sha384 => 48, + Self::Sha512 => 64, + } + } + fn block(self) -> usize { + match self { + Self::Sha384 | Self::Sha512 => 128, + _ => 64, + } + } + fn from_oid(oid: Oid, hmac: bool) -> Result { + let choices = if hmac { + [ + "1.2.840.113549.2.7", + "1.2.840.113549.2.8", + "1.2.840.113549.2.9", + "1.2.840.113549.2.10", + "1.2.840.113549.2.11", + ] + } else { + [ + "1.3.14.3.2.26", + "2.16.840.1.101.3.4.2.4", + "2.16.840.1.101.3.4.2.1", + "2.16.840.1.101.3.4.2.2", + "2.16.840.1.101.3.4.2.3", + ] + }; + for (text, hash) in choices.into_iter().zip([ + Self::Sha1, + Self::Sha224, + Self::Sha256, + Self::Sha384, + Self::Sha512, + ]) { + if oid == Oid::new_unwrap(text) { + return Ok(hash); + } + } + malformed() + } +} + +macro_rules! with_hash { + ($hash:expr, $digest:ident, $body:expr) => { + match $hash { + Hash::Sha1 => { + type $digest = sha1::Sha1; + $body + } + Hash::Sha224 => { + type $digest = sha2::Sha224; + $body + } + Hash::Sha256 => { + type $digest = sha2::Sha256; + $body + } + Hash::Sha384 => { + type $digest = sha2::Sha384; + $body + } + Hash::Sha512 => { + type $digest = sha2::Sha512; + $body + } + } + }; +} + +struct Mac<'a> { + hash: Hash, + digest: Bytes<'a>, + salt: Bytes<'a>, + rounds: u32, +} +impl<'a> Mac<'a> { + fn parse(encoded: Tlv<'a>, budget: &mut Budget<'_>) -> Result { + let mut mac = Reader(encoded.value); + let mut digest_info = Reader(mac.take(0x30)?.value); + let mut algorithm = Reader(digest_info.take(0x30)?.value); + let hash = Hash::from_oid(algorithm.oid()?, false)?; + algorithm.null_or_absent()?; + let (value, rest) = tlv(digest_info.0, 0)?; + let digest = octets(value, 4, budget)?; + digest_info.0 = rest; + digest_info.finish()?; + if digest.len() != hash.size() { + return malformed(); + } + let (salt_tlv, rest) = tlv(mac.0, 0)?; + let salt = octets(salt_tlv, 4, budget)?; + mac.0 = rest; + let rounds = if mac.0.is_empty() { 1 } else { mac.integer()? }; + mac.finish()?; + budget.kdf(rounds, 1, &salt)?; + Ok(Self { + hash, + digest, + salt, + rounds, + }) + } + fn verify(&self, bytes: &[u8], password: &[u8], budget: &Budget<'_>) -> Result<()> { + budget.legacy_workspace(&self.salt, password, self.hash.block(), self.hash.size())?; + let key = Zeroizing::new(with_hash!( + self.hash, + D, + derive_key::( + password, + &self.salt, + Pkcs12KeyType::Mac, + self.rounds as i32, + self.hash.size() + ) + )); + with_hash!(self.hash, D, { + let mut mac = + Hmac::::new_from_slice(&key).map_err(|_| KeyStoreError::ProtectedContainer)?; + mac.update(bytes); + mac.verify_slice(&self.digest) + .map_err(|_| KeyStoreError::ProtectedContainer) + }) + } +} + +#[derive(Clone, Copy)] +enum Cipher { + Aes128, + Aes192, + Aes256, + TripleDes, + DoubleDes, +} +impl Cipher { + fn key_len(self) -> usize { + match self { + Self::Aes128 | Self::DoubleDes => 16, + Self::Aes192 | Self::TripleDes => 24, + Self::Aes256 => 32, + } + } + fn block(self) -> usize { + match self { + Self::TripleDes | Self::DoubleDes => 8, + _ => 16, + } + } +} + +struct Encryption<'a> { + cipher: Cipher, + salt: Bytes<'a>, + rounds: u32, + hash: Option, + iv: &'a [u8], +} +impl<'a> Encryption<'a> { + fn parse(encoded: Tlv<'a>, budget: &mut Budget<'_>) -> Result { + let mut algorithm = Reader(encoded.value); + let oid = algorithm.oid()?; + let mut params = Reader(algorithm.take(0x30)?.value); + algorithm.finish()?; + let (cipher, salt, rounds, hash, iv); + if oid == PBES2 { + let mut kdf = Reader(params.take(0x30)?.value); + if kdf.oid()? != PBKDF2 { + return malformed(); + } + let mut derivation = Reader(kdf.take(0x30)?.value); + kdf.finish()?; + let (value, rest) = tlv(derivation.0, 0)?; + salt = octets(value, 4, budget)?; + derivation.0 = rest; + rounds = derivation.integer()?; + let length = if derivation.0.first() == Some(&2) { + Some(derivation.integer()?) + } else { + None + }; + let mut prf = Hash::Sha1; + if !derivation.0.is_empty() { + let mut algorithm = Reader(derivation.take(0x30)?.value); + prf = Hash::from_oid(algorithm.oid()?, true)?; + algorithm.null_or_absent()?; + } + derivation.finish()?; + let mut scheme = Reader(params.take(0x30)?.value); + let oid = scheme.oid()?; + cipher = if oid == pkcs8::pkcs5::pbes2::AES_128_CBC_OID { + Cipher::Aes128 + } else if oid == pkcs8::pkcs5::pbes2::AES_192_CBC_OID { + Cipher::Aes192 + } else if oid == pkcs8::pkcs5::pbes2::AES_256_CBC_OID { + Cipher::Aes256 + } else { + return malformed(); + }; + iv = scheme.take(4)?.value; + scheme.finish()?; + if iv.len() != cipher.block() + || length.is_some_and(|length| length as usize != cipher.key_len()) + { + return malformed(); + } + hash = Some(prf); + budget.kdf(rounds, cipher.key_len().div_ceil(prf.size()), &salt)?; + } else { + cipher = if oid == pkcs12::PKCS_12_PBE_WITH_SHAAND3_KEY_TRIPLE_DES_CBC { + Cipher::TripleDes + } else if oid == pkcs12::PKCS_12_PBE_WITH_SHAAND2_KEY_TRIPLE_DES_CBC { + Cipher::DoubleDes + } else { + return malformed(); + }; + let (value, rest) = tlv(params.0, 0)?; + salt = octets(value, 4, budget)?; + params.0 = rest; + rounds = params.integer()?; + hash = None; + iv = &[]; + // Appendix B.2 derives key and IV separately; 24-byte SHA-1 + // keys need two digest blocks, not one iteration charge. + budget.kdf(rounds, cipher.key_len().div_ceil(20) + 1, &salt)?; + } + params.finish()?; + Ok(Self { + cipher, + salt, + rounds, + hash, + iv, + }) + } + + fn decrypt( + &self, + ciphertext: &[u8], + password: &mut Password<'_>, + budget: &mut Budget<'_>, + ) -> Result>> { + if ciphertext.is_empty() || !ciphertext.len().is_multiple_of(self.cipher.block()) { + return malformed(); + } + let mut key = Zeroizing::new([0_u8; 32]); + let mut iv = Zeroizing::new([0_u8; 16]); + if let Some(hash) = self.hash { + with_hash!( + hash, + D, + pbkdf2::pbkdf2_hmac::( + password.utf8.as_bytes(), + &self.salt, + self.rounds, + &mut key[..self.cipher.key_len()] + ) + ); + iv[..self.iv.len()].copy_from_slice(self.iv); + } else { + let bmp = password.bmp(budget)?; + budget.legacy_workspace(&self.salt, bmp, 64, self.cipher.key_len())?; + let derived = Zeroizing::new(derive_key::( + bmp, + &self.salt, + Pkcs12KeyType::EncryptionKey, + self.rounds as i32, + self.cipher.key_len(), + )); + key[..derived.len()].copy_from_slice(&derived); + drop(derived); + let derived = Zeroizing::new(derive_key::( + bmp, + &self.salt, + Pkcs12KeyType::Iv, + self.rounds as i32, + 8, + )); + iv[..8].copy_from_slice(&derived); + drop(derived); + } + let mut plaintext = budget.copy(ciphertext)?; + macro_rules! decrypt { + ($cipher:ty) => { + cbc::Decryptor::<$cipher>::new_from_slices( + &key[..self.cipher.key_len()], + &iv[..self.cipher.block()], + ) + .map_err(|_| KeyStoreError::ProtectedContainer)? + .decrypt_padded::(&mut plaintext) + .map_err(|_| KeyStoreError::ProtectedContainer)? + .len() + }; + } + let length = match self.cipher { + Cipher::Aes128 => decrypt!(aes::Aes128Dec), + Cipher::Aes192 => decrypt!(aes::Aes192Dec), + Cipher::Aes256 => decrypt!(aes::Aes256Dec), + Cipher::TripleDes => decrypt!(des::TdesEde3), + Cipher::DoubleDes => decrypt!(des::TdesEde2), + }; + plaintext.truncate(length); + Ok(plaintext) + } +} + +fn content_info<'a>(encoded: Tlv<'a>) -> Result<(Oid, Tlv<'a>)> { + let mut info = Reader(encoded.value); + let oid = info.oid()?; + let explicit = info.take(0xa0)?; + info.finish()?; + let (content, rest) = tlv(explicit.value, 0)?; + Reader(rest).finish()?; + Ok((oid, content)) +} + +fn encrypted_content<'a>( + encoded: Tlv<'a>, + budget: &mut Budget<'_>, +) -> Result<(Encryption<'a>, Bytes<'a>)> { + if encoded.tag != 0x30 { + return malformed(); + } + let mut data = Reader(encoded.value); + if data.integer()? != 0 { + return malformed(); + } + let mut info = Reader(data.take(0x30)?.value); + data.finish()?; + if info.oid()? != DATA { + return malformed(); + } + let encryption = Encryption::parse(info.take(0x30)?, budget)?; + let (value, rest) = tlv(info.0, 0)?; + Reader(rest).finish()?; + Ok((encryption, octets(value, 0x80, budget)?)) +} + +struct Password<'a> { + utf8: &'a str, + bmp: Option>>, +} + +impl Password<'_> { + fn bmp(&mut self, budget: &mut Budget<'_>) -> Result<&[u8]> { + if self.bmp.is_none() { + // RFC 7292 B.1's BMPString conversion applies to its legacy KDF, + // not PBES2 (RFC 8018 6.2). Convert lazily so UTF-8 PBES2-only + // containers neither allocate this buffer nor reject non-BMP text. + // https://www.rfc-editor.org/rfc/rfc7292#appendix-B.1 + // https://www.rfc-editor.org/rfc/rfc8018#section-6.2 + let mut units = 1_usize; + for ch in self.utf8.chars() { + if u32::from(ch) > u16::MAX as u32 { + return malformed(); + } + units = units + .checked_add(1) + .ok_or(KeyStoreError::ProtectedContainer)?; + } + let size = units + .checked_mul(2) + .ok_or(KeyStoreError::ProtectedContainer)?; + budget.allocate(size)?; + let mut bmp = Zeroizing::new(Vec::with_capacity(size)); + for ch in self.utf8.chars() { + bmp.extend_from_slice(&(u32::from(ch) as u16).to_be_bytes()); + } + bmp.extend_from_slice(&[0, 0]); + self.bmp = Some(bmp); + } + self.bmp + .as_deref() + .map(|bytes| bytes.as_slice()) + .ok_or(KeyStoreError::ProtectedContainer) + } +} + +fn validate_attribute_values(mut bytes: &[u8], depth: usize) -> Result<()> { + if depth >= crate::hard_limits::PKCS12_NESTING_CEILING { + return malformed(); + } + while !bytes.is_empty() { + let (value, rest) = tlv(bytes, depth)?; + if value.tag & 0x20 != 0 { + validate_attribute_values(value.value, depth + 1)?; + } + bytes = rest; + } + Ok(()) +} + +fn validate_attributes(mut attributes: Reader<'_>) -> Result<()> { + // RFC 7292 4.2 defines each optional PKCS12Attribute as an OID and + // a SET OF values. Ignoring an attribute's meaning does not waive its + // framing; validate without retaining or decoding the metadata. + // https://www.rfc-editor.org/rfc/rfc7292#section-4.2 + while !attributes.0.is_empty() { + let mut attribute = Reader(attributes.take(0x30)?.value); + attribute.oid()?; + validate_attribute_values(attribute.take(0x31)?.value, 0)?; + attribute.finish()?; + } + Ok(()) +} + +fn safe_contents( + bytes: &[u8], + budget: &mut Budget<'_>, + mut password: Option<&mut Password<'_>>, + contents: &mut Contents, + depth: usize, +) -> Result<()> { + if depth >= crate::hard_limits::PKCS12_NESTING_CEILING { + return malformed(); + } + let mut safe = Reader::sequence(bytes)?; + while !safe.0.is_empty() { + budget.count(true)?; + let mut bag = Reader(safe.take(0x30)?.value); + let oid = bag.oid()?; + let value = bag.take(0xa0)?.value; + if !bag.0.is_empty() { + validate_attributes(Reader(bag.take(0x31)?.value))?; + } + bag.finish()?; + if oid == pkcs12::PKCS_12_SAFE_CONTENTS_BAG_OID { + safe_contents(value, budget, password.as_deref_mut(), contents, depth + 1)?; + } else if oid == pkcs12::PKCS_12_PKCS8_KEY_BAG_OID { + let mut key = Reader::sequence(value)?; + let encryption = Encryption::parse(key.take(0x30)?, budget)?; + let (encrypted, rest) = tlv(key.0, 0)?; + Reader(rest).finish()?; + let encrypted = octets(encrypted, 4, budget)?; + if let Some(password) = password.as_deref_mut() { + if !contents.private_keys.is_empty() { + return Err(KeyStoreError::Selection( + "PKCS#12 bundle must contain exactly one private key", + )); + } + budget.allocate(core::mem::size_of::>>())?; + contents.private_keys.reserve_exact(1); + contents + .private_keys + .push(encryption.decrypt(&encrypted, password, budget)?); + } + encrypted.release(budget); + encryption.salt.release(budget); + } else if oid == pkcs12::PKCS_12_KEY_BAG_OID { + if password.is_some() { + if !contents.private_keys.is_empty() { + return Err(KeyStoreError::Selection( + "PKCS#12 bundle must contain exactly one private key", + )); + } + budget.allocate(core::mem::size_of::>>())?; + contents.private_keys.reserve_exact(1); + contents.private_keys.push(budget.copy(value)?); + } + } else if oid == pkcs12::PKCS_12_CERT_BAG_OID { + let mut cert = Reader::sequence(value)?; + if cert.oid()? != pkcs12::PKCS_12_X509_CERT_OID { + return malformed(); + } + let explicit = cert.take(0xa0)?; + cert.finish()?; + let (value, rest) = tlv(explicit.value, 0)?; + Reader(rest).finish()?; + let certificate = octets(value, 4, budget)?; + if password.is_some() { + if contents.certificates.capacity() == 0 { + // Reserve the bounded bag allowance only when a certificate + // actually exists, avoiding speculative allocation for + // key-only containers and growth during hidden-bag traversal. + budget.allocate(budget.limits.candidates * core::mem::size_of::>())?; + contents + .certificates + .reserve_exact(budget.limits.candidates); + } + // Retained public certificate is independent of temporary decrypted bags. + budget.allocate(certificate.len())?; + contents.certificates.push(certificate.to_vec()); + } + certificate.release(budget); + } else { + return Err(KeyStoreError::Selection("unsupported PKCS#12 bag type")); + } + } + Ok(()) +} + +fn walk_safe( + bytes: &[u8], + budget: &mut Budget<'_>, + mut password: Option<&mut Password<'_>>, + contents: &mut Contents, +) -> Result<()> { + let mut safe = Reader::sequence(bytes)?; + while !safe.0.is_empty() { + budget.count(false)?; + let (oid, content) = content_info(safe.take(0x30)?)?; + if oid == DATA { + let data = octets(content, 4, budget)?; + safe_contents(&data, budget, password.as_deref_mut(), contents, 0)?; + data.release(budget); + } else if oid == ENCRYPTED_DATA { + let (encryption, encrypted) = encrypted_content(content, budget)?; + if let Some(password) = password.as_deref_mut() { + let data = encryption.decrypt(&encrypted, password, budget)?; + // RFC 7292 4.1/4.2.2 allows shrouded bags inside encrypted + // SafeContents. Their parameters cannot be known before the + // password; the shared budget checks them before their KDF. + // https://www.rfc-editor.org/rfc/rfc7292#section-4.1 + safe_contents(&data, budget, Some(password), contents, 0)?; + budget.memory -= data.capacity(); + } + encrypted.release(budget); + encryption.salt.release(budget); + } else { + return Err(KeyStoreError::Selection("unsupported PKCS#12 privacy mode")); + } + } + Ok(()) +} + +struct Pfx<'a> { + safe: Bytes<'a>, + mac: Option>, +} +impl<'a> Pfx<'a> { + fn parse(bytes: &'a [u8], budget: &mut Budget<'_>) -> Result { + let mut pfx = Reader::sequence(bytes)?; + if pfx.integer()? != 3 { + return malformed(); + } + let (oid, content) = content_info(pfx.take(0x30)?)?; + if oid != DATA { + return Err(KeyStoreError::Selection( + "unsupported PKCS#12 integrity mode", + )); + } + let safe = octets(content, 4, budget)?; + let mac = if pfx.0.is_empty() { + None + } else { + Some(Mac::parse(pfx.take(0x30)?, budget)?) + }; + pfx.finish()?; + Ok(Self { safe, mac }) + } +} + +pub(super) struct Prepared<'a, 'l> { + pfx: Pfx<'a>, + limits: &'l Limits, +} + +pub(super) fn prepare<'a, 'l>(bytes: &'a [u8], limits: &'l Limits) -> Result> { + let mut budget = Budget::new(limits); + let pfx = Pfx::parse(bytes, &mut budget)?; + walk_safe( + &pfx.safe, + &mut budget, + None, + &mut Contents { + private_keys: Vec::new(), + certificates: Vec::new(), + }, + )?; + Ok(Prepared { pfx, limits }) +} + +impl Prepared<'_, '_> { + pub(super) fn decrypt(self, password: &str) -> Result { + let Self { pfx, limits } = self; + let mut budget = Budget::new(limits); + budget.allocate(pfx.safe.owned_capacity())?; + if let Some(mac) = &pfx.mac { + budget.allocate(mac.salt.owned_capacity() + mac.digest.owned_capacity())?; + budget.kdf(mac.rounds, 1, &mac.salt)?; + } + let mut password = Password { + utf8: password, + bmp: None, + }; + if let Some(mac) = &pfx.mac { + mac.verify(&pfx.safe, password.bmp(&mut budget)?, &budget)?; + } + let mut contents = Contents { + private_keys: Vec::new(), + certificates: Vec::new(), + }; + walk_safe(&pfx.safe, &mut budget, Some(&mut password), &mut contents)?; + Ok(contents) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aes::cipher::BlockModeEncrypt as _; + + fn encoded(tag: u8, value: &[u8]) -> Vec { + let mut out = vec![tag]; + if value.len() < 128 { + out.push(value.len() as u8); + } else { + out.push(0x82); + out.extend_from_slice(&(value.len() as u16).to_be_bytes()); + } + out.extend_from_slice(value); + out + } + fn sequence(parts: &[Vec]) -> Vec { + encoded(0x30, &parts.concat()) + } + fn oid(value: Oid) -> Vec { + encoded(6, value.as_bytes()) + } + fn integer(value: u16) -> Vec { + let mut bytes = value.to_be_bytes().to_vec(); + if bytes[0] == 0 && bytes[1] < 128 { + bytes.remove(0); + } else if bytes[0] & 128 != 0 { + bytes.insert(0, 0); + } + encoded(2, &bytes) + } + fn bag(kind: Oid, value: &[u8]) -> Vec { + sequence(&[oid(kind), encoded(0xa0, value)]) + } + fn data(safe: &[u8]) -> Vec { + sequence(&[oid(DATA), encoded(0xa0, &encoded(4, safe))]) + } + fn pfx(infos: &[Vec]) -> Vec { + sequence(&[integer(3), data(&sequence(infos))]) + } + fn limits(candidates: usize) -> Limits { + Limits { + resources: ResourcePolicy::default(), + candidates, + memory_available: ResourcePolicy::default().max_external_resource_total_bytes, + } + } + + #[test] + fn malformed_bag_attributes_are_rejected_before_password() { + // Optional attributes are still ASN.1 Attribute records, not an + // unchecked opaque tail that can hide malformed BER. + let key = sequence(&[ + oid(pkcs12::PKCS_12_KEY_BAG_OID), + encoded(0xa0, &[0x30, 0]), + encoded(0x31, &[0xff]), + ]); + assert!(prepare(&pfx(&[data(&sequence(&[key]))]), &limits(64)).is_err()); + } + + #[test] + fn redundant_integer_octets_are_not_ber() { + // X.690 8.3.2 disallows a redundant leading zero even for BER. + assert!(Reader(&[2, 2, 0, 3]).integer().is_err()); + } + + #[test] + fn nested_bags_share_candidate_count() { + // A nested SafeContentsBag is not a reset of the outer bag budget. + let key = bag(pkcs12::PKCS_12_KEY_BAG_OID, &[0x30, 0]); + let nested = bag(pkcs12::PKCS_12_SAFE_CONTENTS_BAG_OID, &sequence(&[key])); + assert!(matches!( + prepare(&pfx(&[data(&sequence(&[nested]))]), &limits(1)), + Err(KeyStoreError::Policy( + PolicyViolation::ResourceLimitExceeded { + resource: resource_name::KEY_CANDIDATES, + maximum: 1 + } + )) + )); + } + + #[test] + fn constructed_octets_preserve_mac_input_and_ownership() { + // Constructed OCTET STRING concatenates primitive contents; its + // allocation must be charged once and released by retained capacity. + let value = [0x24, 0x80, 4, 2, b'a', b'b', 0x24, 3, 4, 1, b'c', 0, 0]; + let limits = limits(64); + let mut budget = Budget::new(&limits); + let bytes = octets(tlv(&value, 0).expect("BER").0, 4, &mut budget).expect("flatten"); + assert_eq!(&*bytes, b"abc"); + assert_eq!(budget.memory, 3); + bytes.release(&mut budget); + assert_eq!(budget.memory, 0); + assert!(tlv(&[4, 0x80, 0, 0], 0).is_err()); + assert!(tlv(&[0x30, 0x80, 4, 1, 7], 0).is_err()); + } + + #[test] + fn legacy_shrouded_key_and_hidden_limits() { + // Exercise RustCrypto's PKCS#12 KDF with legacy SHA-1/3DES, then + // prove an encrypted SafeContents cannot reset the inner KDF budget. + let password = "secret"; + let bmp: Vec = password + .encode_utf16() + .chain([0]) + .flat_map(u16::to_be_bytes) + .collect(); + let salt = b"12345678"; + let key = derive_key::(&bmp, salt, Pkcs12KeyType::EncryptionKey, 2, 24); + let iv = derive_key::(&bmp, salt, Pkcs12KeyType::Iv, 2, 8); + let algorithm = sequence(&[ + oid(pkcs12::PKCS_12_PBE_WITH_SHAAND3_KEY_TRIPLE_DES_CBC), + sequence(&[encoded(4, salt), integer(2)]), + ]); + let private = pem::parse(include_bytes!( + "../../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("key") + .into_contents(); + let encrypt = |bytes: &[u8]| { + let mut output = vec![0; bytes.len() + 8]; + cbc::Encryptor::::new_from_slices(&key, &iv) + .expect("cipher") + .encrypt_padded_b2b::(bytes, &mut output) + .expect("padding") + .to_vec() + }; + let shrouded = bag( + pkcs12::PKCS_12_PKCS8_KEY_BAG_OID, + &sequence(&[algorithm.clone(), encoded(4, &encrypt(&private))]), + ); + let bytes = pfx(&[data(&sequence(std::slice::from_ref(&shrouded)))]); + let limits = limits(64); + let contents = prepare(&bytes, &limits) + .expect("preflight") + .decrypt(password) + .expect("legacy import"); + assert_eq!(&*contents.private_keys[0], &private); + assert!( + prepare(&bytes, &limits) + .expect("preflight") + .decrypt("wrong") + .is_err() + ); + let encrypted_safe = sequence(&[ + oid(ENCRYPTED_DATA), + encoded( + 0xa0, + &sequence(&[ + integer(0), + sequence(&[ + oid(DATA), + algorithm, + encoded(0x80, &encrypt(&sequence(&[shrouded]))), + ]), + ]), + ), + ]); + let bytes = pfx(&[encrypted_safe]); + let tight = Limits { + resources: ResourcePolicy { + max_key_import_kdf_work: 6, + ..ResourcePolicy::default() + }, + candidates: 64, + memory_available: ResourcePolicy::default().max_external_resource_total_bytes, + }; + let prepared = prepare(&bytes, &tight).expect("outer KDF fits"); + assert!(matches!( + prepared.decrypt(password), + Err(KeyStoreError::Policy( + PolicyViolation::ResourceLimitExceeded { + resource: resource_name::KEY_IMPORT_KDF_WORK, + maximum: 6 + } + )) + )); + } + + #[test] + fn pbes2_cipher_prf_matrix_accepts_utf8_passwords_without_legacy_kdf() { + // RFC 8018 PBES2 does not impose RFC 7292's legacy BMP password + // conversion. Test every supported AES width and HMAC PRF with a + // non-BMP UTF-8 password, including the default SHA-1 PRF. + let password = "secret\u{1f512}"; + let private = pem::parse(include_bytes!( + "../../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("private key") + .into_contents(); + let salt = b"salt beyond the old fixed thirty-two byte representation"; + let iv = [7_u8; 16]; + let prfs = [ + (Hash::Sha1, "1.2.840.113549.2.7"), + (Hash::Sha224, "1.2.840.113549.2.8"), + (Hash::Sha256, "1.2.840.113549.2.9"), + (Hash::Sha384, "1.2.840.113549.2.10"), + (Hash::Sha512, "1.2.840.113549.2.11"), + ]; + for (cipher, cipher_oid) in [ + (Cipher::Aes128, pkcs8::pkcs5::pbes2::AES_128_CBC_OID), + (Cipher::Aes192, pkcs8::pkcs5::pbes2::AES_192_CBC_OID), + (Cipher::Aes256, pkcs8::pkcs5::pbes2::AES_256_CBC_OID), + ] { + for (hash, prf_oid) in prfs { + let mut key = Zeroizing::new([0_u8; 32]); + with_hash!( + hash, + D, + pbkdf2::pbkdf2_hmac::( + password.as_bytes(), + salt, + 2, + &mut key[..cipher.key_len()] + ) + ); + let mut output = vec![0; private.len() + 16]; + macro_rules! encrypt { + ($cipher:ty) => { + cbc::Encryptor::<$cipher>::new_from_slices(&key[..cipher.key_len()], &iv) + .expect("cipher") + .encrypt_padded_b2b::(&private, &mut output) + .expect("padding") + .to_vec() + }; + } + let ciphertext = match cipher { + Cipher::Aes128 => encrypt!(aes::Aes128Enc), + Cipher::Aes192 => encrypt!(aes::Aes192Enc), + Cipher::Aes256 => encrypt!(aes::Aes256Enc), + _ => unreachable!(), + }; + let mut params = vec![ + encoded(4, salt), + integer(2), + integer(cipher.key_len() as u16), + ]; + if !matches!(hash, Hash::Sha1) { + params.push(sequence(&[oid(Oid::new_unwrap(prf_oid)), encoded(5, &[])])); + } + let algorithm = sequence(&[ + oid(PBES2), + sequence(&[ + sequence(&[oid(PBKDF2), sequence(¶ms)]), + sequence(&[oid(cipher_oid), encoded(4, &iv)]), + ]), + ]); + let shrouded = bag( + pkcs12::PKCS_12_PKCS8_KEY_BAG_OID, + &sequence(&[algorithm, encoded(4, &ciphertext)]), + ); + let bytes = pfx(&[data(&sequence(&[shrouded]))]); + let limits = limits(64); + let contents = prepare(&bytes, &limits) + .expect("PBES2 preflight") + .decrypt(password) + .expect("UTF-8 PBES2 import"); + assert_eq!(&*contents.private_keys[0], &private); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 7f7e65e3..ec0ad758 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -101,6 +101,8 @@ pub use xml::dom::{ ParsingOptions as XmlDomParsingOptions, XmlBackend, }; +#[cfg(feature = "xmldsig")] +pub mod key_manager; #[cfg(feature = "xmldsig")] pub mod xmldsig; diff --git a/src/policy.rs b/src/policy.rs index 77b11520..66b0baef 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -40,6 +40,8 @@ pub(crate) mod resource_name { pub const ENCRYPTION_RECIPIENTS: &str = "encryption recipients"; pub const ENCRYPTION_METADATA_BYTES: &str = "encryption metadata bytes"; pub const KEY_CANDIDATES: &str = "key candidates"; + pub const KEY_IMPORT_KDF_WORK: &str = "key import KDF work"; + pub const KEY_IMPORT_KDF_MEMORY: &str = "key import KDF memory bytes"; pub const KEY_INFO_REFERENCE_DEPTH: &str = "KeyInfoReference depth"; pub const BASE64_TRANSFORM_INPUT_BYTES: &str = "Base64 transform input bytes"; pub const BASE64_TRANSFORM_OUTPUT_BYTES: &str = "Base64 transform output bytes"; @@ -93,6 +95,20 @@ pub enum PolicyViolation { /// Observed consumption. actual: usize, }, + /// An import exceeded a resource ceiling, but its parser did not expose the measured value. + #[error("{resource} exceeds policy maximum {maximum}")] + ResourceLimitExceeded { + /// Resource whose consumption was rejected. + resource: &'static str, + /// Effective policy ceiling. + maximum: usize, + }, + /// A protected import supplied zero or too many KDF iterations; the parser did not expose the count. + #[error("key import KDF iterations must be between 1 and {maximum}")] + KdfIterationsOutsideLimit { + /// Effective iteration ceiling. + maximum: usize, + }, /// A configured resource limit violates a structural policy requirement. #[error("{resource} has invalid policy limit {actual}: {requirement}")] InvalidResourceLimit { @@ -412,6 +428,10 @@ pub struct ResourcePolicy { /// Maximum key-source expansion work and concrete key or certificate /// candidates inspected by one operation stage. pub max_key_candidates: usize, + /// Maximum aggregate PBKDF2/PKCS#12 hash rounds or conservative scrypt work during key import. + pub max_key_import_kdf_work: usize, + /// Maximum estimated scrypt or PKCS#12 KDF workspace bytes during key import. + pub max_key_import_kdf_memory_bytes: usize, /// Maximum nested `KeyInfoReference` dereference depth. pub max_key_info_reference_depth: usize, /// Maximum bytes accepted by Base64 transforms before decoding. @@ -469,6 +489,8 @@ impl Default for ResourcePolicy { max_encryption_recipients: crate::hard_limits::ENCRYPTION_RECIPIENT_CEILING, max_encryption_metadata_bytes: crate::hard_limits::ENCRYPTION_METADATA_BYTE_CEILING, max_key_candidates: crate::hard_limits::KEY_CANDIDATE_CEILING, + max_key_import_kdf_work: crate::hard_limits::KEY_IMPORT_KDF_WORK_CEILING as usize, + max_key_import_kdf_memory_bytes: crate::hard_limits::KEY_IMPORT_KDF_MEMORY_CEILING, max_key_info_reference_depth: crate::hard_limits::KEY_INFO_REFERENCE_DEPTH_CEILING, max_base64_transform_input_bytes: crate::hard_limits::BASE64_TRANSFORM_INPUT_BYTE_CEILING, @@ -578,6 +600,16 @@ impl ResourcePolicy { self.max_key_candidates, crate::hard_limits::KEY_CANDIDATE_CEILING, ), + ( + resource_name::KEY_IMPORT_KDF_WORK, + self.max_key_import_kdf_work, + crate::hard_limits::KEY_IMPORT_KDF_WORK_CEILING as usize, + ), + ( + resource_name::KEY_IMPORT_KDF_MEMORY, + self.max_key_import_kdf_memory_bytes, + crate::hard_limits::KEY_IMPORT_KDF_MEMORY_CEILING, + ), ( resource_name::KEY_INFO_REFERENCE_DEPTH, self.max_key_info_reference_depth, @@ -1439,6 +1471,8 @@ mod tests { max_encryption_recipients: 0, max_encryption_metadata_bytes: 0, max_key_candidates: 0, + max_key_import_kdf_work: 0, + max_key_import_kdf_memory_bytes: 0, max_key_info_reference_depth: 0, max_base64_transform_input_bytes: 0, max_base64_transform_output_bytes: 0, diff --git a/src/provider.rs b/src/provider.rs index 4e4bae29..4f9d68a0 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -908,7 +908,8 @@ mod rustcrypto_x509 { // Certificate signatures are ASN.1 DER integers sized by the // issuer's q parameter. XMLDSig's fixed 20-byte r||s framing // applies only to SignatureValue, never to X.509 signatures. - let Ok(key) = dsa::VerifyingKey::from_public_key_der(issuer_spki_der) else { + let Ok(key) = crate::xmldsig::signature::decode_dsa_verifying_key(issuer_spki_der) + else { return Ok(false); }; let Ok(signature) = dsa::Signature::from_der(signature) else { diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 227a88d2..69288094 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -3,8 +3,9 @@ use std::{collections::HashMap, fmt, time::SystemTime}; use crypto_bigint::BoxedUint; -use dsa::pkcs8::{DecodePublicKey as DsaDecodePublicKey, EncodePublicKey as DsaEncodePublicKey}; +use dsa::pkcs8::EncodePublicKey as DsaEncodePublicKey; use hmac::{KeyInit, Mac}; +use rsa::pkcs8::DecodePublicKey as _; use x509_parser::{ prelude::{FromDer, X509Certificate}, public_key::PublicKey, @@ -13,8 +14,9 @@ use x509_parser::{ use zeroize::Zeroizing; use super::signature::{ - signature_value_matches_spki, signature_value_matches_spki_with_encoding, - validate_dsa_signature_spki_with_minimum, validate_rsa_signature_spki_with_minimum, + decode_dsa_verifying_key, signature_value_matches_spki, + signature_value_matches_spki_with_encoding, validate_dsa_signature_spki_with_minimum, + validate_ec_public_key_encoding, validate_rsa_signature_spki_with_minimum, verify_dsa_signature_spki_primitive, verify_dsa_signature_spki_with_minimum, verify_rsa_signature_spki_primitive, verify_rsa_signature_spki_with_minimum, }; @@ -29,7 +31,7 @@ use super::{ x509_data_has_lookup_identifiers, x509_selector_categories_match_chain, }, verify_ecdsa_signature_spki, verify_ecdsa_signature_spki_with_encoding, - x509::verify_x509_certificate_chain_with_provider, + x509::verify_x509_certificate_chain_with_provider_and_crls, }; /// Caller-owned HMAC verification key. @@ -444,6 +446,8 @@ pub struct KeyResolverConfig { pub lookup_certs: Vec>, /// DER-encoded certificates accepted as trust anchors. pub trusted_certs: Vec>, + /// Caller-owned revocation evidence applied without copying it into each XML source. + pub crls: Vec>, /// Verification keys addressable by `` content. pub named_keys: HashMap, } @@ -454,43 +458,53 @@ pub struct DefaultKeyResolver { config: KeyResolverConfig, } +#[derive(Clone, Copy)] +pub(crate) enum ResolutionScope { + Document, + Trusted, + DocumentPrefix(usize), + TrustedPrefix(usize), +} + /// Counts candidates actually inspected by one resolver invocation. /// /// Parser cardinality preflights prevent expensive materialization, but do not /// replace this runtime accounting: embedded and indirect candidates both /// consume resolver work when inspected. -struct InspectedKeyCandidateBudget { +#[derive(Clone, Copy)] +pub(crate) struct InspectedKeyCandidateBudget { maximum: usize, attempted: usize, } impl InspectedKeyCandidateBudget { - fn new(maximum: usize) -> Self { + pub(crate) fn new(maximum: usize) -> Self { Self { maximum, attempted: 0, } } - fn charge(&mut self) -> Result<(), DsigError> { + pub(crate) fn charge(&mut self) -> Result<(), DsigError> { self.charge_many(1) } - fn charge_many(&mut self, count: usize) -> Result<(), DsigError> { - self.attempted = self.attempted.saturating_add(count); - if self.attempted > self.maximum { + pub(crate) fn charge_many(&mut self, count: usize) -> Result<(), DsigError> { + debug_assert!(self.attempted <= self.maximum); + if count > self.maximum - self.attempted { return Err(crate::policy::PolicyViolation::ResourceLimit { resource: crate::policy::resource_name::KEY_CANDIDATES, maximum: self.maximum, - actual: self.attempted, + actual: self.attempted.saturating_add(count), } .into()); } + self.attempted += count; Ok(()) } } -fn validate_key_info_source_permissions( +pub(crate) fn validate_key_info_source_permissions( key_info: &KeyInfo, allowed: crate::policy::KeySourcePolicy, ) -> Result<(), crate::policy::PolicyViolation> { @@ -526,6 +540,60 @@ fn validate_key_info_source_permissions( } impl DefaultKeyResolver { + pub(crate) fn resolve_with_candidate_budget( + &self, + key_info: Option<&KeyInfo>, + algorithm: SignatureAlgorithm, + policy: &crate::policy::VerificationPolicy, + provider: &dyn crate::provider::CryptoProvider, + candidate_budget: &mut InspectedKeyCandidateBudget, + ) -> Result>, DsigError> { + self.resolve_with_trust( + key_info, + algorithm, + policy, + provider, + candidate_budget, + ResolutionScope::Document, + ) + } + + pub(crate) fn resolve_trusted_material_with_candidate_budget( + &self, + key_info: &KeyInfo, + algorithm: SignatureAlgorithm, + policy: &crate::policy::VerificationPolicy, + provider: &dyn crate::provider::CryptoProvider, + candidate_budget: &mut InspectedKeyCandidateBudget, + ) -> Result>, DsigError> { + self.resolve_with_trust( + Some(key_info), + algorithm, + policy, + provider, + candidate_budget, + ResolutionScope::Trusted, + ) + } + + pub(crate) fn resolve_prefix_with_candidate_budget( + &self, + key_info: &KeyInfo, + algorithm: SignatureAlgorithm, + policy: &crate::policy::VerificationPolicy, + provider: &dyn crate::provider::CryptoProvider, + candidate_budget: &mut InspectedKeyCandidateBudget, + scope: ResolutionScope, + ) -> Result>, DsigError> { + self.resolve_with_trust( + Some(key_info), + algorithm, + policy, + provider, + candidate_budget, + scope, + ) + } /// Construct a resolver from explicit caller-owned key and certificate stores. #[must_use] pub fn new(config: KeyResolverConfig) -> Self { @@ -538,6 +606,49 @@ impl DefaultKeyResolver { &self.config } + fn check_configured_x509_material( + &self, + resources: &crate::policy::ResourcePolicy, + trust: &crate::policy::KeyTrustPolicy, + budget: &mut InspectedKeyCandidateBudget, + ) -> Result<(), DsigError> { + if trust.check_crls && trust.verify_x509_chains { + budget.charge_many(self.config.crls.len())?; + } + let certificates = self + .config + .trusted_certs + .iter() + .chain(&self.config.lookup_certs); + let crls = self + .config + .crls + .iter() + .filter(|_| trust.check_crls && trust.verify_x509_chains); + let mut total = 0_usize; + for material in certificates.chain(crls) { + if material.len() > resources.max_external_resource_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_bytes, + actual: material.len(), + } + .into()); + } + debug_assert!(total <= resources.max_external_resource_total_bytes); + if material.len() > resources.max_external_resource_total_bytes - total { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_total_bytes, + actual: total.saturating_add(material.len()), + } + .into()); + } + total += material.len(); + } + Ok(()) + } + fn resolve_x509( &self, info: &X509DataInfo, @@ -599,7 +710,12 @@ impl DefaultKeyResolver { rsa_keys: trust.rsa_keys, dsa_keys: trust.dsa_keys, }; - verify_x509_certificate_chain_with_provider(info, &options, provider)?; + verify_x509_certificate_chain_with_provider_and_crls( + info, + &options, + provider, + &self.config.crls, + )?; Ok(()) } @@ -970,27 +1086,52 @@ impl DefaultKeyResolver { })) } - fn resolve_with_trust<'a>( - &'a self, + fn resolve_with_trust( + &self, key_info: Option<&KeyInfo>, algorithm: SignatureAlgorithm, - sources: crate::policy::KeySourcePolicy, - trust: &crate::policy::KeyTrustPolicy, - resources: &crate::policy::ResourcePolicy, + policy: &crate::policy::VerificationPolicy, provider: &dyn crate::provider::CryptoProvider, - ) -> Result>, DsigError> { + candidate_budget: &mut InspectedKeyCandidateBudget, + scope: ResolutionScope, + ) -> Result>, DsigError> { + let trust = &policy.key_trust; + let resources = &policy.resources; trust.validate()?; resources.validate()?; let Some(key_info) = key_info else { return Ok(None); }; - validate_key_info_source_permissions(key_info, sources)?; - let mut candidate_budget = InspectedKeyCandidateBudget::new(resources.max_key_candidates); + let document_sources = matches!( + scope, + ResolutionScope::Document | ResolutionScope::DocumentPrefix(_) + ); + if document_sources { + validate_key_info_source_permissions(key_info, policy.key_sources)?; + } + let source_end = match scope { + ResolutionScope::DocumentPrefix(end) | ResolutionScope::TrustedPrefix(end) => end, + _ => key_info.sources.len(), + }; let mut deferred_key_value_error = None; - for source in &key_info.sources { + let mut configured_material_checked = false; + for source in &key_info.sources[..source_end] { + if !document_sources && matches!(source, KeyInfoSource::KeyName(_)) { + continue; + } let resolved = match source { KeyInfoSource::X509Data(info) => { - self.resolve_x509(info, algorithm, trust, provider, &mut candidate_budget)? + if !configured_material_checked + && (if info.certificate_chain.is_empty() { + x509_data_has_lookup_identifiers(info) + } else { + trust.verify_x509_chains + }) + { + self.check_configured_x509_material(resources, trust, candidate_budget)?; + configured_material_checked = true; + } + self.resolve_x509(info, algorithm, trust, provider, candidate_budget)? } KeyInfoSource::DerEncodedKeyValue(public_key_bytes) => { candidate_budget.charge()?; @@ -1058,13 +1199,15 @@ impl KeyResolver for DefaultKeyResolver { algorithm: SignatureAlgorithm, ) -> Result>, DsigError> { let policy = crate::policy::VerificationPolicy::default(); + let mut candidate_budget = + InspectedKeyCandidateBudget::new(policy.resources.max_key_candidates); self.resolve_with_trust( key_info, algorithm, - policy.key_sources, - &policy.key_trust, - &policy.resources, + &policy, crate::provider::default_provider(), + &mut candidate_budget, + ResolutionScope::Document, ) } @@ -1089,13 +1232,14 @@ impl KeyResolver for DefaultKeyResolver { policy: &crate::policy::VerificationPolicy, provider: &dyn crate::provider::CryptoProvider, ) -> Result>, DsigError> { - self.resolve_with_trust( + let mut candidate_budget = + InspectedKeyCandidateBudget::new(policy.resources.max_key_candidates); + self.resolve_with_candidate_budget( key_info, algorithm, - policy.key_sources, - &policy.key_trust, - &policy.resources, + policy, provider, + &mut candidate_budget, ) } @@ -1151,6 +1295,7 @@ fn rsa_key_value_to_spki_der( modulus: &[u8], exponent: &[u8], ) -> Result, KeyResolutionError> { + let (modulus, exponent) = bounded_rsa_public_components(modulus, exponent)?; let key = rsa::RsaPublicKey::new( BoxedUint::from_be_slice_vartime(modulus), BoxedUint::from_be_slice_vartime(exponent), @@ -1161,6 +1306,30 @@ fn rsa_key_value_to_spki_der( .map(|der| der.as_bytes().to_vec()) } +pub(crate) fn bounded_rsa_public_components<'a>( + modulus: &'a [u8], + exponent: &'a [u8], +) -> Result<(&'a [u8], &'a [u8]), KeyResolutionError> { + let modulus = &modulus[modulus + .iter() + .position(|byte| *byte != 0) + .unwrap_or(modulus.len())..]; + let exponent = &exponent[exponent + .iter() + .position(|byte| *byte != 0) + .unwrap_or(exponent.len())..]; + let maximum = crate::hard_limits::RSA_MODULUS_BIT_CEILING; + if modulus.is_empty() + || exponent.is_empty() + || modulus.len() > maximum.div_ceil(8) + || exponent.len() > maximum.div_ceil(8) + || (modulus.len() * 8 - modulus[0].leading_zeros() as usize) > maximum + { + return Err(KeyResolutionError::InvalidPublicKey); + } + Ok((modulus, exponent)) +} + fn dsa_key_value_to_spki_der( p: &[u8], q: &[u8], @@ -1234,8 +1403,8 @@ fn validate_spki_algorithm( .map(|oid| oid.to_id_string()); match (algorithm, parsed) { (SignatureAlgorithm::DsaSha1 | SignatureAlgorithm::DsaSha256, PublicKey::DSA(_)) => { - let _ = dsa::VerifyingKey::from_public_key_der(public_key_bytes) - .map_err(|_| KeyResolutionError::AlgorithmMismatch)?; + let _ = decode_dsa_verifying_key(public_key_bytes) + .map_err(|_| KeyResolutionError::InvalidPublicKey)?; Ok(()) } ( @@ -1252,24 +1421,101 @@ fn validate_spki_algorithm( | SignatureAlgorithm::EcdsaSha256 | SignatureAlgorithm::EcdsaSha384 | SignatureAlgorithm::EcdsaSha512, - PublicKey::EC(_), + PublicKey::EC(ec), ) if matches!( curve_oid.as_deref(), Some(EC_P256_OID | EC_P384_OID | EC_P521_OID) ) => { + validate_ec_public_key_encoding(&ec, spki.subject_public_key.data.as_ref()) + .map_err(|_| KeyResolutionError::InvalidPublicKey)?; + validate_ec_point(curve_oid.as_deref(), spki.subject_public_key.data.as_ref())?; Ok(()) } _ => Err(KeyResolutionError::AlgorithmMismatch), } } +pub(crate) fn supported_parsed_spki_is_rsa( + spki: &SubjectPublicKeyInfo<'_>, + public_key_bytes: &[u8], +) -> Result { + let parsed = spki + .parsed() + .map_err(|_| KeyResolutionError::InvalidPublicKey)?; + match parsed { + PublicKey::RSA(key) => { + bounded_rsa_public_components(key.modulus, key.exponent)?; + rsa::RsaPublicKey::from_public_key_der(public_key_bytes) + .map_err(|_| KeyResolutionError::InvalidPublicKey)?; + Ok(true) + } + PublicKey::DSA(_) => { + let _ = decode_dsa_verifying_key(public_key_bytes) + .map_err(|_| KeyResolutionError::InvalidPublicKey)?; + Ok(false) + } + PublicKey::EC(ec) => { + validate_ec_public_key_encoding(&ec, spki.subject_public_key.data.as_ref()) + .map_err(|_| KeyResolutionError::InvalidPublicKey)?; + let curve_oid = spki + .algorithm + .parameters + .as_ref() + .and_then(|value| value.as_oid().ok()) + .map(|oid| oid.to_id_string()); + validate_ec_point(curve_oid.as_deref(), spki.subject_public_key.data.as_ref())?; + Ok(false) + } + _ => Err(KeyResolutionError::AlgorithmMismatch), + } +} + +fn validate_ec_point(curve_oid: Option<&str>, point: &[u8]) -> Result<(), KeyResolutionError> { + match curve_oid { + Some(EC_P256_OID) => p256::PublicKey::from_sec1_bytes(point).map(|_| ()), + Some(EC_P384_OID) => p384::PublicKey::from_sec1_bytes(point).map(|_| ()), + Some(EC_P521_OID) => p521::PublicKey::from_sec1_bytes(point).map(|_| ()), + _ => return Err(KeyResolutionError::AlgorithmMismatch), + } + .map_err(|_| KeyResolutionError::InvalidPublicKey) +} + +pub(crate) fn supported_key_value_is_rsa(value: &KeyValueInfo) -> Result { + let (spki, is_rsa) = match value { + KeyValueInfo::Rsa { modulus, exponent } => { + (rsa_key_value_to_spki_der(modulus, exponent)?, true) + } + KeyValueInfo::Dsa { + p: Some(p), + q: Some(q), + g: Some(g), + y, + } => (dsa_key_value_to_spki_der(p, q, g, y)?, false), + KeyValueInfo::Ec { + curve_oid, + public_key, + } => (ec_key_value_to_spki_der(curve_oid, public_key)?, false), + _ => return Err(KeyResolutionError::InvalidPublicKey), + }; + let algorithm = if is_rsa { + SignatureAlgorithm::RsaSha256 + } else if matches!(value, KeyValueInfo::Dsa { .. }) { + SignatureAlgorithm::DsaSha256 + } else { + SignatureAlgorithm::EcdsaSha256 + }; + validate_spki_algorithm(&spki, algorithm)?; + Ok(is_rsa) +} + #[cfg(test)] mod tests { use crate::xml::dom as roxmltree; use std::sync::atomic::{AtomicUsize, Ordering}; use base64::{Engine, engine::general_purpose::STANDARD}; + use der::Decode as _; use rcgen::{ CertificateRevocationListParams, Issuer, KeyIdMethod, KeyPair, KeyUsagePurpose, RevokedCertParams, SerialNumber, date_time_ymd, @@ -1278,6 +1524,50 @@ mod tests { use super::*; + #[test] + fn xml_rsa_components_are_bounded_before_bigint_decode() { + // KeyValue import must reject oversized decoded modulus before conversion. + let oversized = vec![1_u8; crate::hard_limits::RSA_MODULUS_BIT_CEILING.div_ceil(8) + 1]; + assert!(matches!( + rsa_key_value_to_spki_der(&oversized, &[1, 0, 1]), + Err(KeyResolutionError::InvalidPublicKey) + )); + } + + #[test] + fn oversized_dsa_spki_parameter_is_rejected_before_bigint_decode() { + // A bounded SPKI may still contain a parameter much larger than the + // non-configurable DSA component ceiling. + let oversized = vec![1_u8; crate::hard_limits::DSA_KEY_COMPONENT_BYTE_CEILING + 1]; + let one = [1_u8]; + let params = der::Encode::to_der(&super::super::signature::BorrowedDsaPublicParameters { + p: der::asn1::UintRef::new(&oversized).expect("positive P"), + q: der::asn1::UintRef::new(&one).expect("positive Q"), + g: der::asn1::UintRef::new(&one).expect("positive G"), + }) + .expect("parameters encode"); + let y = der::Encode::to_der(&der::asn1::UintRef::new(&one).expect("positive Y")) + .expect("public value encodes"); + let spki = rsa::pkcs8::SubjectPublicKeyInfoRef { + algorithm: rsa::pkcs8::AlgorithmIdentifierRef { + oid: dsa::OID, + parameters: Some(der::asn1::AnyRef::from_der(¶ms).expect("parameters")), + }, + subject_public_key: der::asn1::BitStringRef::new(0, &y).expect("bit string"), + }; + let encoded = der::Encode::to_der(&spki).expect("SPKI encodes"); + assert!(matches!( + decode_dsa_verifying_key(&encoded), + Err(super::super::signature::SignatureVerificationError::InvalidKeyDer) + )); + // Ordinary verification must use the same borrowed preflight, not + // reject only after an allocating crypto decoder reports mismatch. + assert!(matches!( + validate_spki_algorithm(&encoded, SignatureAlgorithm::DsaSha256), + Err(KeyResolutionError::InvalidPublicKey) + )); + } + struct RejectSecondSha512Provider { sha512_calls: AtomicUsize, verification_calls: AtomicUsize, @@ -3329,6 +3619,134 @@ mod tests { )); } + #[test] + fn configured_crls_are_bounded_before_der_parsing() { + // Invalid DER must not be parsed when its size or count already violates policy. + let key_info = KeyInfo { + sources: vec![KeyInfoSource::X509Data(X509DataInfo { + subject_names: vec!["CN=selected".into()], + ..X509DataInfo::default() + })], + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.key_trust.check_crls = true; + policy.key_trust.verify_x509_chains = true; + policy.resources.max_external_resource_bytes = 4; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + crls: vec![vec![0; 5]], + ..KeyResolverConfig::default() + }); + let error = resolver + .resolve_with_policy(Some(&key_info), SignatureAlgorithm::RsaSha256, &policy) + .err() + .expect("oversized CRL must fail before DER parsing"); + assert!( + matches!( + error, + DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + .. + }) + ), + "{error:?}" + ); + + policy.resources.max_external_resource_bytes = 4; + policy.resources.max_external_resource_total_bytes = 7; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + crls: vec![vec![0; 4], vec![0; 4]], + ..KeyResolverConfig::default() + }); + let error = resolver + .resolve_with_policy(Some(&key_info), SignatureAlgorithm::RsaSha256, &policy) + .err() + .expect("aggregate CRL bytes must fail before DER parsing"); + assert!(matches!( + error, + DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + .. + }) + )); + + policy.resources.max_external_resource_total_bytes = 8; + policy.resources.max_key_candidates = 1; + let error = resolver + .resolve_with_policy(Some(&key_info), SignatureAlgorithm::RsaSha256, &policy) + .err() + .expect("CRL candidate count must fail before DER parsing"); + assert!(matches!( + error, + DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::KEY_CANDIDATES, + .. + }) + )); + } + + #[test] + fn configured_certificates_and_crls_share_external_byte_budget() { + // A stricter operation policy must account for all resolver-owned + // material on a selector path, even when the resolver was built under + // a broader policy. + let key_info = KeyInfo { + sources: vec![KeyInfoSource::X509Data(X509DataInfo { + subject_names: vec!["CN=selected".into()], + ..X509DataInfo::default() + })], + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.key_trust.check_crls = true; + policy.key_trust.verify_x509_chains = true; + policy.resources.max_external_resource_bytes = 8; + policy.resources.max_external_resource_total_bytes = 12; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![vec![0; 8]], + crls: vec![vec![0; 8]], + ..KeyResolverConfig::default() + }); + let error = resolver + .resolve_with_policy(Some(&key_info), SignatureAlgorithm::RsaSha256, &policy) + .err() + .expect("combined external material exceeds the operation limit"); + assert!(matches!( + error, + DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + .. + }) + )); + } + + #[test] + fn direct_certificate_does_not_charge_unused_configured_store() { + // A direct embedded certificate bypasses configured lookup material + // when chain verification is disabled. + let certificate = certificate_der(RSA_4096_CERTIFICATE); + let key_info = KeyInfo { + sources: vec![KeyInfoSource::X509Data(X509DataInfo { + certificates: vec![certificate], + certificate_chain: vec![0], + subject_names: vec!["CN=unused-selector".into()], + ..X509DataInfo::default() + })], + }; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![vec![0; 4096]], + trusted_certs: vec![vec![0; 4096]], + ..KeyResolverConfig::default() + }); + let mut policy = crate::policy::VerificationPolicy::default(); + policy.resources.max_external_resource_bytes = 1024; + policy.resources.max_external_resource_total_bytes = 1024; + assert!( + resolver + .resolve_with_policy(Some(&key_info), SignatureAlgorithm::RsaSha256, &policy) + .expect("unused configured certificates are not charged") + .is_some() + ); + } + #[test] fn operation_policy_bounds_embedded_x509_certificate_candidates() { // Embedded X509Data is also composite key material. Its certificate diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index 86c5e7df..e094f953 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -404,16 +404,8 @@ fn expected_signature_output_len( .dsa_component_len() .expect("DSA algorithm matched above"); if component_len != required_component_len { - return Err(crate::policy::PolicyViolation::InvalidKeyMaterial { - operation: "signing", - key_type: "DSA", - reason: match algorithm { - SignatureAlgorithm::DsaSha1 => "DSA-SHA1 requires a 160-bit q parameter", - SignatureAlgorithm::DsaSha256 => { - "DSA-SHA256 requires a 256-bit q parameter" - } - _ => unreachable!("DSA algorithm matched above"), - }, + return Err(SigningKeyError::UnsupportedAlgorithm { + uri: algorithm.uri().to_owned(), } .into()); } @@ -3552,6 +3544,45 @@ mod error_conversion_tests { struct FixedRsaSigningKey; + struct WrongWidthDsaSigningKey; + + impl SigningKey for WrongWidthDsaSigningKey { + fn sign( + &self, + _algorithm: SignatureAlgorithm, + _canonical_signed_info: &[u8], + ) -> Result, SigningKeyError> { + unreachable!("preflight must reject this candidate") + } + + fn public_key_info(&self) -> Result { + Ok(SigningPublicKeyInfo::Dsa { + spki_der: Vec::new(), + p: Vec::new(), + q: Vec::new(), + g: Vec::new(), + y: Vec::new(), + modulus_bits: 2048, + component_len: 20, + }) + } + } + + #[test] + fn dsa_q_width_mismatch_is_candidate_incompatibility() { + // Lax search may skip an incompatible key but must not skip policy failures. + let error = validate_signing_key( + &WrongWidthDsaSigningKey, + SignatureAlgorithm::DsaSha256, + &crate::policy::SigningPolicy::default(), + ) + .expect_err("SHA-256 requires a 256-bit q"); + assert!(matches!( + error, + SigningError::Key(SigningKeyError::UnsupportedAlgorithm { .. }) + )); + } + impl SigningKey for FixedRsaSigningKey { fn sign( &self, diff --git a/src/xmldsig/signature.rs b/src/xmldsig/signature.rs index d0807f10..eb28afa3 100644 --- a/src/xmldsig/signature.rs +++ b/src/xmldsig/signature.rs @@ -10,6 +10,7 @@ //! - ECDSA keys are validated as uncompressed SEC1 points from the SPKI bit //! string and verified with RustCrypto curve crates (`p256`/`p384`/`p521`). +use der::Decode as _; use p256::ecdsa::{Signature as P256Signature, VerifyingKey as P256VerifyingKey}; use p384::ecdsa::{Signature as P384Signature, VerifyingKey as P384VerifyingKey}; use p521::ecdsa::{Signature as P521Signature, VerifyingKey as P521VerifyingKey}; @@ -119,8 +120,7 @@ pub(crate) fn signature_value_matches_spki_with_encoding( algorithm @ (SignatureAlgorithm::DsaSha1 | SignatureAlgorithm::DsaSha256), PublicKey::DSA(_), ) => { - let key = dsa::VerifyingKey::from_public_key_der(public_key_spki_der) - .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; + let key = decode_dsa_verifying_key(public_key_spki_der)?; let component_len = usize::try_from(key.components().q().bits_vartime()) .map_err(|_| SignatureVerificationError::InvalidKeyDer)? .div_ceil(8); @@ -425,8 +425,7 @@ pub(crate) fn validate_dsa_signature_spki_with_minimum( public_key_spki_der: &[u8], minimum_modulus_bits: usize, ) -> Result<(), SignatureVerificationError> { - let key = dsa::VerifyingKey::from_public_key_der(public_key_spki_der) - .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; + let key = decode_dsa_verifying_key(public_key_spki_der)?; let modulus_bits = usize::try_from(key.components().p().bits_vartime()) .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; crate::policy::DsaKeyPolicy { @@ -436,6 +435,39 @@ pub(crate) fn validate_dsa_signature_spki_with_minimum( .map_err(SignatureVerificationError::KeyPolicy) } +#[derive(der::Sequence)] +pub(crate) struct BorrowedDsaPublicParameters<'a> { + pub(crate) p: der::asn1::UintRef<'a>, + pub(crate) q: der::asn1::UintRef<'a>, + pub(crate) g: der::asn1::UintRef<'a>, +} + +pub(crate) fn decode_dsa_verifying_key( + bytes: &[u8], +) -> Result { + // Component size is a process-safety bound, not a DSA conformance rule. + // Inspect borrowed DER integers before any allocating bigint conversion, + // including certificate signatures and signature-framing checks. + let spki = rsa::pkcs8::SubjectPublicKeyInfoRef::from_der(bytes) + .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; + let parameters = spki + .algorithm + .parameters + .as_ref() + .ok_or(SignatureVerificationError::InvalidKeyDer)? + .decode_as::>() + .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; + let y = der::asn1::UintRef::from_der(spki.subject_public_key.raw_bytes()) + .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; + for component in [parameters.p, parameters.q, parameters.g, y] { + if component.as_bytes().len() > crate::hard_limits::DSA_KEY_COMPONENT_BYTE_CEILING { + return Err(SignatureVerificationError::InvalidKeyDer); + } + } + dsa::VerifyingKey::from_public_key_der(bytes) + .map_err(|_| SignatureVerificationError::InvalidKeyDer) +} + pub(crate) fn verify_dsa_signature_spki_primitive( algorithm: SignatureAlgorithm, public_key_spki_der: &[u8], @@ -450,8 +482,7 @@ pub(crate) fn verify_dsa_signature_spki_primitive( uri: algorithm.uri().to_string(), }); } - let key = dsa::VerifyingKey::from_public_key_der(public_key_spki_der) - .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; + let key = decode_dsa_verifying_key(public_key_spki_der)?; let component_len = usize::try_from(key.components().q().bits_vartime()) .map_err(|_| SignatureVerificationError::InvalidKeyDer)? .div_ceil(8); @@ -954,7 +985,7 @@ fn parse_der_length(input: &[u8]) -> Option> { Some(Ok((declared_len, remainder))) } -fn validate_ec_public_key_encoding( +pub(crate) fn validate_ec_public_key_encoding( ec: &ECPoint<'_>, public_key_bytes: &[u8], ) -> Result<(), SignatureVerificationError> { @@ -964,6 +995,9 @@ fn validate_ec_public_key_encoding( .and_then(|len| len.checked_add(1)) .ok_or(SignatureVerificationError::InvalidKeyDer)?; + // RFC 5480 §2.2 permits, but does not require, compressed points: + // https://www.rfc-editor.org/rfc/rfc5480.html#section-2.2 . This implementation + // uses the uncompressed profile consistently for import and verification. let is_uncompressed_sec1 = public_key_bytes.len() == expected_len && public_key_bytes.first() == Some(&0x04); if !is_uncompressed_sec1 { diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index ada6e836..109c5e48 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -157,6 +157,15 @@ pub(crate) fn verify_x509_certificate_chain_with_provider( info: &X509DataInfo, options: &X509ChainOptions<'_>, provider: &dyn crate::provider::CryptoProvider, +) -> Result<(), X509ChainError> { + verify_x509_certificate_chain_with_provider_and_crls(info, options, provider, &[]) +} + +pub(crate) fn verify_x509_certificate_chain_with_provider_and_crls( + info: &X509DataInfo, + options: &X509ChainOptions<'_>, + provider: &dyn crate::provider::CryptoProvider, + additional_crls: &[Vec], ) -> Result<(), X509ChainError> { if options.max_chain_depth == 0 { return Err(X509ChainError::InvalidDepth); @@ -190,7 +199,14 @@ pub(crate) fn verify_x509_certificate_chain_with_provider( let verification_time = system_time_to_asn1(options.verification_time)?; let embedded_anchor = trusted_anchors.iter().any(|(der, _)| *der == last.as_raw()); if embedded_anchor { - return validate_path(&path_der, info, options, verification_time, provider); + return validate_path( + &path_der, + info, + options, + verification_time, + provider, + additional_crls, + ); } // Use the path-edge verifier here too: x509-parser does not verify legacy @@ -226,7 +242,14 @@ pub(crate) fn verify_x509_certificate_chain_with_provider( } let mut candidate_path = candidate_base.to_vec(); candidate_path.push(anchor_der); - match validate_path(&candidate_path, info, options, verification_time, provider) { + match validate_path( + &candidate_path, + info, + options, + verification_time, + provider, + additional_crls, + ) { Ok(()) => return Ok(()), Err(error) => first_validation_error.get_or_insert(error), }; @@ -241,6 +264,7 @@ fn validate_path( options: &X509ChainOptions<'_>, verification_time: ASN1Time, provider: &dyn crate::provider::CryptoProvider, + additional_crls: &[Vec], ) -> Result<(), X509ChainError> { if path_der.len() > options.max_chain_depth { return Err(X509ChainError::DepthExceeded(options.max_chain_depth)); @@ -283,7 +307,13 @@ fn validate_path( } if options.check_crls { - verify_crls(&path, &info.crls, verification_time, provider)?; + verify_crls( + &path, + &info.crls, + additional_crls, + verification_time, + provider, + )?; } Ok(()) } @@ -1669,11 +1699,13 @@ fn validate_crl_extension_semantics( fn verify_crls( path: &[X509Certificate<'_>], crl_der: &[Vec], + additional_crls: &[Vec], verification_time: ASN1Time, provider: &dyn crate::provider::CryptoProvider, ) -> Result<(), X509ChainError> { let crls = crl_der .iter() + .chain(additional_crls) .enumerate() .map(|(idx, der)| { let (rest, crl) = CertificateRevocationList::from_der(der).map_err(|error| { diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index 60c2e276..3e5d2b89 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -1193,7 +1193,10 @@ fn projected_decoded_len_for_encoded_len(encoded_len: usize) -> usize { .unwrap_or(usize::MAX) } -fn validate_key_len(algorithm: DataEncryptionAlgorithm, key: &[u8]) -> Result<(), XmlEncError> { +pub(crate) fn validate_key_len( + algorithm: DataEncryptionAlgorithm, + key: &[u8], +) -> Result<(), XmlEncError> { if key.len() == algorithm.key_len() { Ok(()) } else { diff --git a/src/xmlenc/mod.rs b/src/xmlenc/mod.rs index c5474838..61288a63 100644 --- a/src/xmlenc/mod.rs +++ b/src/xmlenc/mod.rs @@ -15,6 +15,7 @@ use crate::xml::dom::Node; mod decrypt; +pub(crate) use decrypt::validate_key_len; mod encrypt; mod parse; mod types; diff --git a/tests/donor_interop_suite.rs b/tests/donor_interop_suite.rs index f5f29eb8..2cd5c7c2 100644 --- a/tests/donor_interop_suite.rs +++ b/tests/donor_interop_suite.rs @@ -1060,12 +1060,8 @@ fn dsa_sha1_rejects_a_key_with_a_256_bit_q() { assert!(matches!( validate_signing_key(&key, SignatureAlgorithm::DsaSha1, &policy), - Err(xml_sec::xmldsig::SigningError::Policy( - PolicyViolation::InvalidKeyMaterial { - operation: "signing", - key_type: "DSA", - reason: "DSA-SHA1 requires a 160-bit q parameter", - } + Err(xml_sec::xmldsig::SigningError::Key( + xml_sec::xmldsig::SigningKeyError::UnsupportedAlgorithm { .. } )) )); assert!(matches!( diff --git a/tests/fixtures/keys/pkcs12/ec-key.p12.b64 b/tests/fixtures/keys/pkcs12/ec-key.p12.b64 new file mode 100644 index 00000000..472d037c --- /dev/null +++ b/tests/fixtures/keys/pkcs12/ec-key.p12.b64 @@ -0,0 +1 @@ +MIIG9AIBAzCCBqIGCSqGSIb3DQEHAaCCBpMEggaPMIIGizCCBToGCSqGSIb3DQEHBqCCBSswggUnAgEAMIIFIAYJKoZIhvcNAQcBMF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBDF8jFgy5BFSkZmfCc9oOZAAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQMNsCiqbHJDBc+puNj8UO/4CCBLBQYh3ukIuJSb7/qGOG7r3pWyl4nF3562UnXWWyJo3Okm1/FGEVIxneSRLkEet5WGylojpc2IAmUhfVEyufWj2B5vPvxIZVOupf5baLCwjzVA2404aDvejBCgsrNJEpdwENi6ep00gRdBKjTogSpHiRFeu0t90zP6yybK41m+bi/U05NkHPYsIGGK6nGSJc7MxEFD7ynXPd7ebOXT6VrvqYgI97MUnplnOxAonBGWDUE6vTF5y7YXvGRM+s/zqrSWE/bwR37NjjlLhYI3ZKe4B5uxBKF400XBOsTbq+56B+CtomnHSxy5mo9GwNt9MR9zp0uH7kw0mSv/IETySUjs6RgyvmPx8izyxijGG4sCW4GBbZm8MW2cPTwo2oQ/LxBm6qP3AHveR6TBpmKRCtJ0fSIttLh/xN1taskzTuxoL+GHL45DhKUqupxPhYufPOKdLGiHAQAMV9wCBfHrSplC+KIXFG1m7Duit5sVt2GaHBZ6CsroiYsCRAca7RUL7oZvv8folwgz7EmMr1X0kJTOy+g7KnQg4IX58MTj6TwZZTO06ADp44leqbZ905V2HN8uDKKHDep/qXiMTbRztEn2YJcjEcLuSxpgTSDKgFWqtFRb84X9Am4Q/YAa7rPtyF+w2YvtA0TzBc+7Oyfrh7cZV6e8Yb5YwTxdwOPxZ+g/9V+gQtU3759o8QMjFtFhlVnfPnw6hVRYYzUk8dWcp0bx89GgjvAcPBmsICLfzc2/gc8F1aN95kUhVQNfmAa8SykxxMcKsre2C4CVzrhu8cBNOYQtecOH/WKpg9b+yYdUxb+Lyn9t8mumLkThIF+sTz65XnTdLON5jh1rq+Nnz9cFXhIiPaaC32REhXUAFDI4FbJ47hdqWBjmivncCHTWr82k9YWcxxB4d7iAoiczJOBxD7kQLb0DRGzhHAi1trsYcAL+zwH4cWGXPKCCPErHBUKPDSPywBsHC0pbQctIgEx2sEfCxHnkEhWMLV8/WhLDXybXnPzieLGMW/0ic3BNXb8K3+pFID1UQkFAsMIWCuRBw7oo/Oz8FhyfEHMIIxoouwsQdvDwd4b4cCs5nN3KK4cvGa7lray15WTEBuWkHtGkDO5n7k79AE+g/J+rL3Es48zlVjDqtJzZOrfn7RwtdELBaTtGJxz3/np8cvMr3hqs6/WT71WxgNpWdZnjcuW+W7Dq+Nvo0xMZ3Q7Zit0End+UoKZ7Pkt1ptU7ByMevzpmjcgA0chTUVx+8uT2HPIwj/cl8mCfYoyoxKHNoiSRMu2vl0Q74NpoZJTWdzMt/tkyw/Hd3AKBt9fPPbOkAmphFmGiKGjJdSTbDrkjMsi8ySEFtF+i4eT973xnBgb07LyCe5uZ9AXdKhvoFp+XHMUJuNFy9uBAZoN+AaCKFnzcSDXaxcHFbiCP6gbdzGMrG8wHm6vHJ7GQgfcyO6Z4teku6F6qi8hYTE+cPhMFHzHnSCXzLs4ynpDpyD46GbC/bHIhX+KKHtLsMzUR64AygYmZ6AS0pfv1fCh8ZSX02MgmD1zRLKu82twQAsQpyBAKunTPMjpDxSmjulqvqM5hytfw0MOLmDeMTeFNiHOghaI3K3lyQwggFJBgkqhkiG9w0BBwGgggE6BIIBNjCCATIwggEuBgsqhkiG9w0BDAoBAqCB9zCB9DBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQmV2bAWuiNtONQA14Ges4qQICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEBbOBNRA9zT7rLnUxeI0S2QEgZACTyB1VnU40cExSi8LiUSA8rbOFV535K8POpblEKm1DibOL3+4kiGPl7y4OF+h8FXu2QaYNMBq3tS8iy0hTNuUX+fgdoBtOcu/e+M1aTTKAZot6jkSD9me9Jzh4DB2V/qljLYinLmw2+CVOBdJ5lfPptDqLzDjU+a3Y1X2XOgHPhqBxWJGi0P2b6eMl+VAC6MxJTAjBgkqhkiG9w0BCRUxFgQUVACtd4hYFvB8+PJY2wGb4ncfW8kwSTAxMA0GCWCGSAFlAwQCAQUABCCsR2MBWsvUXExk13oX98r/dNVk73FHP8L67yIU2F1HPwQQgem2ZJRcJZl2sVa6fs+SSQICCAA= diff --git a/tests/fixtures/keys/pkcs12/rsa-duplicate-leaf.p12.b64 b/tests/fixtures/keys/pkcs12/rsa-duplicate-leaf.p12.b64 new file mode 100644 index 00000000..c95f426d --- /dev/null +++ b/tests/fixtures/keys/pkcs12/rsa-duplicate-leaf.p12.b64 @@ -0,0 +1 @@ +MIIRRwIBAzCCEPUGCSqGSIb3DQEHAaCCEOYEghDiMIIQ3jCCC0oGCSqGSIb3DQEHBqCCCzswggs3AgEAMIILMAYJKoZIhvcNAQcBMF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBDp80yMBPDVAoNEW2A4/JNGAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQocmW/MTbN7AclJ9pBPZTtoCCCsDxfu93HYYqx+ZzE3wtLecx6GF1jQ36UCRMrSxfzXbth14mkLL5lEsPz4XrlTu60wfw8Vd8M5G//8cHAMDpHh+0R1eZC6K1+dog8vew4oYmCaKRVODQqWqY7/lUbGAFZ+LSsJQ3ZdAAte1oQb3xKsLlwISQs5mwyM2fSfWaSowI+6fzimm59zCISzpS311JNOh4+0Ad+TlQbsAApfZl2jy+XemU4bKy52Eo92aW0U9aex1+66mV7p2eBhBLbimwyqkg+BXwwMmcFN2zWLS2opIAL0qsIjUkHx/7rjzopwM64+dcQucY9JZSliLmaOmywqkV5L7RQZdb0zs1CkqRKscgt++Kgf3a/kk2iqli3IAq/2IyVmt18gFsQno1u0x56InqA/x6yE5D6PsPJjXwrpvLu9Vp6LSwJDK2lYWc93s53aJhgRbhTCYH2Dwl5WpwQTzf7P0odNQ8SX5IvKW4EImXJwuoV2+BO4LzW9MDg3aLTJ3WkEGFkXDNUFlGQPNHlnSp7L9qDMZqDb9bqYqjn22KFPZUt4IqnIysnbPSyK4NQUkRMTmaMQuGwlL9cuwuB27pCnmZSUwefXBA2Qbj7osXymlwmT+u8F4BJqErgzxZKWnJ1AagmOQgWIm0kfL9sXjmTEGZei55Gk1sT6jYYth44hKorlbDFbZ2NIjOJVjZfDsHEumKE7FDUo/3YuAK5kF4IEICco0HO1H9bZ3zK/1SnmTiIr5Q/eY4untQHc7MY6zImyAO5SLwkdPJydCTyHhyP79oX3tIQ4X8Hf8EVOMudlUvRfwBcXrsVd/Wa4OXkAXIUrcp9021OVJhdMox/dOEk/VZTLFM1fp2/3TGYqDfUkyfkK/loG+Hh/3li13mlIydvMb04Ef65Cb3xEr7Myden1MjGXikh2avLh4rTsSm+cQAkdkXVjmPUuUyQNm6m1dDxQ/N1QjvEzDEo+1IhPTmlar3SkyNHFG36zdLBIW+TKTeWKCpkd8U1+fIFJAv/ZBHDiYZvOC5ol2Q3FBpYkf+80Xg6g50kXUHWxa9J32CqMcM4AaWhE5SfYXF16g9vzrx1VbK7JGPf2gsHlO8T3wrA9Ztf7YdHyGSL2xLLpjrB8vQzdR1Dop2qDO5tGSVC91BLmnJtmxdoXzlb0aZgsQJ0Dl7V4r9P935W/TyapMW9Ku/bit7JJG2hIGFyMsglu/QVzAp2KD16wBa+13SV/aY/4PrMie29Ks8IExWCQaDVtQL+liJvy2ioQgLmnzdZHG66TpVd910tS7dHIohUj0+rIti2OYXHWRe+oOBu1se154iiVxJMUOqcbJxMPMUN9zQ1xNYVZ2zgiL+X8xxKHxKJcZV/ZRbDlkUWlxBbkyqiM7MP/qM/vPWW+HoGi8e86UCMRY6zmpNoLp2rD20xrZtj5JSIYMXYNwV/j9lHmbYoHu5tnxuZo7V8XF+4knzmuXCMUABgSMPI6a61mij95myovF498cQTgPvfZBL3/F+qCTrEvM/oeB+ORsEwkSXH6J8edo80tRUi0BQ0+Hp4J3HzW4soN2BbsgOynWp3EtpmxCl8jYixH2idMrb/GqrNcp3udsuxwDGRvKdFzVSln9t/sbzlw0tB/kD8anQjQ1i5D2+4Tq4enn30C7PS2tMNpThxdROynKVDS6diNtzpjRBCMGBiPl2UZnrpAsiEpnFXPOQdQMxehlXGmy0dWp314+pQD7SeLOVWSzFlqBAnikkV9jLQApCBqPp0s04Cw8gMSwyPT9OSZRpsu0Vo38xbBT2LImxBJ2DGKWI/KS8Jx32BLrr2569c+XpjzEnEDLybGPHWNf7CZpGBadtPA6bXIn1hZoquomxW1Do3NbTsI5BwAzUxdgMVBzbMJ6Ob9IToZu0Wo7H9JHPyy6SArYgK8Fpdk1ZZ7vFmLUzUjS6s1Xr0asPjrLJKi8KCDJCDWV+IG+Ls2snsjew2k/PsaHbQn6I3VO5LZHBKmE1BGE9mDjx8GSnl9ITa3e21iD/6BHo9buRAhRZtqM46Qg8DDNuidvQOcR2X6vrW43LfVcsTMhPcGL7eTCf6pJjhIO8oirXmUcr2u6BZBh8P7HgwZHMQ0bcWuHUdf0q8IfFIq+MyfoMpTgl/HS18ks/SCyUrmb02F0x3fSSEZSNwfrwFRhppVd3aviBuF/492JUBHpbACq2XCKU+P+mzS6WKY05sAU44S2bz9Pw4SYdafXav5hn9YwklK/jbgtT7RFQi+dFwqiDNNcMKGYcYhTlR8JB2ObjqwiINtfSOVCEwcAcsBGKw3z6vO177rKdurheejqKTqZpX1WrFeSTMe33pE2wIq6MJfXCyVV8UVbzl0Gx2ZschYqsJB7jZ2tlqGg4cV13pVsvCRlMGWCBGAphsn6YMXvPFgoyk+W1J+e5EI4XmXAiKTZj71ccC6lJ5VjEQt5fB+S0Z5mm1YazY4oDnP2EIay8eeXge04OQeXWz8FAa1pcuc1/+0ckaTQlrH3CX83kjD2RaDGrmrwhP5jA5Eq6k+gfD2JYOvHHF8fQ+7gcfe+Uptr6pLXBzlMv5pPJM6pGWt+T/aFGW9lutkIEUxQk7uWjeHjdLmoC2l66XpN50boCxbogO992CuXkrpy6JVF2T9bZxR+pGOVYO4ryNXLHwYgiI8yqsMvpVBm+NwiK43gaFgl17twhfnYHhIIvuiqZy7vbDPx4yqDbD+1UeifwgZSA9LLjWQU2SajMXcYmwwA4LpR2l3uUczEZ6zMdokZ0/eoaEeXtJZrUPXbMA1zUiIcK+rpnd6Rou8Og/XjoGXJBLq7k7cQFz5DfSprIywWEoh3hGaGYmnVcadwp9DPyUwah06LaJt9yJtAeULmxJ7EkOKt7MzdTnXmKo5n2r7yiDmIvIPzmsGw9D6ND/L2hhUh1pWltipM8HQIhgam1KuMCZKD/EUIq6C2Jj4w0P1aEM/nODPfSaYSMgNjOsTqSZGHoi9oJtDS1KoJamfNIAOFnR+pRrxWKgMnCSdgbi6rAuSBd7oDwbv2nCxnvX2ENOynIZYLcyELzOpIv5wo7DJIx72Ukai0CI1IwbC+bmRaQRmE+UdBPgftdDZdUmT1zA/rcWoGES6A4JTugfhXhqlmDxTja3lz23m0F67quy/ejFmFYZKgCUob8ZjebCk03DoMoR44cy5//Hw+jF/8zjN4Ee3FLNoX3uuRz6kh4qPmavhm/z++fR5/fOpvsJ2k8bOyMiSSiWlzwBteURSCsSD6n02NW8CaLGWZRyk4x4f+ZvGHp3gpA7CWiqVCP8qYkMgOssza8J2mWG8/R2XR/PLvFxGOXvz/fskzk+WkunzypVvuFhjg9q+iaNZJjsLXQHlZxDhYIdnDW9Une7nadforPLescQulWQVUVUwRIzH0xFAwb/zZsbZEQLnFMROgGNJPca+4pN1BO7eufjNmzTuIZnZUnM+NndC7UQfOZbagqtKQjOVLuCA04NisUfv0Xj5WtzkgsVLWxkrPLuHWa6oAyBbcvWTpTmESWJQkPfaUW9h8gXsjsD/yxM88DYfsu1gh0pbRNIQwtD83yrEpnazNeaXxCPafUheFE8fAxM3dJL3VJ6muTN3MuOu0giedIv8lnmGaqMtljdQ9A2xjqwLiDnPgJgpgJh2PkEBcQEjETqovxd11y1NYvL1cz62dv9JYBMIIFjAYJKoZIhvcNAQcBoIIFfQSCBXkwggV1MIIFcQYLKoZIhvcNAQwKAQKgggU5MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQwN6CcCxHuiU+/RH+lhWTFQICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEC/CMWOiz5ZG48NYsc3YXogEggTQYnFNjwK9/dPVgmdplmdNjnJg56oM/fD2/KU52i8aE6pk838ZU2pi1kpp5QmOYyyISnPiZXYUNUNFtPn3AkfuA+4jF9XQxrNse/dXAWdPxbauvL2aUyQy8DgQ7OjhEsRwCS/ky6UkKc4yFg/RSSgI3i0kMO/w3eK2YJJSKvCo8ajD+uo1KCz0+oM6ES+PWEN3H/uribO91mokEkhPOUvIXl37KK3bSK3jpyt7ohbT5uuCCR/p9HSoWTVUuZToVmqMU0wGHcjcULjO3nyoNAGUY+W28CHY8TCGGMrxuKtMXT5MKutF7TETM0p5REWgamXPVnTBYpbsp5ec8eZhuONF/DBxa6uaocgTL0OB/58CqN+CYXRHC2nWtURlJ0ZOPJPo2pD7PquG9KVlR13agKV5ZPVG9rehDbIcNtt6UA8MmBdNMeepaU1J1kDxAhMIXLt+EfH42DNwmGMb2QvKBg9Oz4nrCZX7P9O/nbzAKbxFopYLpPlYJNu9dR5mJ6/DtN+Jh/jaM/+USR7gqsdufSuD6+YcA1ku4lmciIamfCInG0XXpqvx9HBL/jHX9Us+SIQ/+Jj5jCpER4zDFDz5b+7xj4WM2F+uSMegZgLXcuv5m73+xBL/JIog30chnK6juYHw0lzRl9FC9HtzekeD6TSJgi4uJTCzGLXm9l3g0q+c+m3YPvsVInowU0rg41PuODXSx/1hHWEKOI/Z60joX2/iyBYw3YyMwkEGiugcBvXvDILAdUmF811RV7mqZ1B/pH6Mlq5X4NIJRO3SPg6f7inWPJ93ob8c54B3+Ayr9qeyF35MyTIhq0oUbzZ0IQWmuubHxvjiNB8wKsMlKnHVcr+g71/+bIXCRKQKffCZ4wwz9tLKJSz/Z5vAUjc6z5fYE26vo5r+z/2EsaKUHzfWsDeHOfYfqfhiOfebR88ycWGMClTFCY7e8Tnx7SJdBL5U5vqQO7IEqJP0ppjB4vxb409i4aSYeWEFfiP+QPqMwACgdgytxCWJTEq0bbj+EKPtiTKuPaYXAK2ixZz0igwVCBL9Zb4Gp6RmmmVn7DVWFKQKstFksdy/HrJ7nGoq37MB3kvxT7pR7kERo1Jw7bCqYM21C8zApaCSmmyqFPi3JT35iJmPub3JuEDdu3xNMqWZc2bzPGvrjHgEoYE+qLN5X7cAsOaT/Azj1bgP1lvsuKO0rfEcG1jInL6TPtIRI8nnHE8/kEt3rUXa6isKXKMipvrZeNBobTSCwLbkJwyuNMhQw59FJb+PxMjpxBfPU1wb01qjtHsp0jlQdV728AEVYlaoeXhlujEmWLH/slrLj2z6uD83MXbrZTNGNoUZ05/Buq8fAbk4RRRmdjkXD8zJEH6+TonALq5Tr2uaH7PJTtDsLkW4UwM0uoqCPSnTgs5ztEI70TDvm+qhnAs3R/aFHrP1eJ9PRXkTcvFRvYjoKzPpXrBorvBpxtiFN6KvGjp6ShZVNLIJRro3ARB2Te0ovjGRvbBIBOBRWF4kBIm2xatBU26Q0wv2bngiippJhnK52RTqfPwbUnwPR9gf8lxNo2/bB/2hiD0QPHgoXIwtfPdqadzx27o7qBqqQnoDZvaq8aTmyXn/n7bZqh0l9O9N1co9ehHATtAPkDedPWl9ibfpFWwxJTAjBgkqhkiG9w0BCRUxFgQU0SRn3soHyXpJTOMGSFEOp8rza/MwSTAxMA0GCWCGSAFlAwQCAQUABCAHt2NQhx5gjkUxcCsLGOv5qRr8usyOtRfe/AWYzNjR9QQQ0PTJogoUfMcxxVRqM1IHmwICCAA= diff --git a/tests/fixtures/keys/pkcs12/rsa-key-unrelated-ca.p12.b64 b/tests/fixtures/keys/pkcs12/rsa-key-unrelated-ca.p12.b64 new file mode 100644 index 00000000..feff37d3 --- /dev/null +++ b/tests/fixtures/keys/pkcs12/rsa-key-unrelated-ca.p12.b64 @@ -0,0 +1 @@ +MIIL4AIBAzCCC44GCSqGSIb3DQEHAaCCC38Eggt7MIILdzCCBgoGCSqGSIb3DQEHBqCCBfswggX3AgEAMIIF8AYJKoZIhvcNAQcBMF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBCqTaaLuBOCLGP6qov0S5G9AgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQxSluuDBJneV63R0w1JkOToCCBYAOoaoKj8oolrey3g9irnf4Po+anKW0pjym+LhXTZ3a1hW11+1ptVJ/Rr+C8BjBug5qW5D1+M7Rpt6S3WRYnBQ3ywOfhrYW5KW/b6KMrrOMTcnWUwY0exi7btr1zNAakEy/F6CPqc/MbRoZD0bFmbkO+bkDfuDjUKSL45jctfj/MjPsPfVGIcHqhmy0EwzOrqt9xfigvPrMdivmG5mOXSRJHrolTzAY0pPlqlWGC8/ksJDIuyejN1cID7LGvLZXNPSwSkiQxWi47poJXnqCWk4rEg/HuoF70EOa6nYFaDsdZCnM2JN1N1mqfwxKtN0tnI/hMKkbu45cGGNv9IEE1DKCOY27UEOA+wQkR3BjtjISwoEmNnnrf29qBp090QsR70UYR7Wku34xZT1vUDHkDiisFiDJG5mHtFfKXJmv66hEOPoMQ+r71qhAld6QfzG7eK2/J0iewA0VrtvP6bDu0pbmutZHFNkbdh7VL1xbjVyG31eYR2IyFTAO+1b/jSSWvLLgOICiIC6huUE8TelkI/qRrTvHqcY1WO09arVFnWaYJl2uRSVBuwEQuU4e/vQPuZHeZUlbglmj+YO83M3lsP+oQSDuMIKwM2XrsdAd6iF2ej+9ufFwmCs/xyToba3jWB+I5WdtpxXUOcOygjMNI+l9w/6HRmhxj1VZCub6IhqOhtlH26FKBCiLyla+CIhyWvXRgUI8oJAM8BZ/WkPgj/OTq9az0JwhTjjRRRefRViAvGTVqd6Bvx2BgNjFLENguiUWtF0UC8+nMQMRekDGTEqyHamqjLcHYljGskAzGVBI5beOlh7O5yPOJORZMY7t0ot0A2e3jqdfa+zwI8o4PyGib1VMhNv1meYrdNdz1ctrvrR+eWxX8IUxT1DP1q3oF3Fq9tSoYydbKcpfZ0oXIYiauQuwxc5nC31LtiwEUqKzgewWD8znJgGV6ixya5vHc95PtOGoRsggIj2NvFHuQzsZHmFwSTdBP1sI2w91oJG7XS4uiJBN6Ufbc7uMBgnzkWlcfCXak6FOR81UcGv6aYrNn1x2v4d91StyvpSgJjmLkAmm5Ae20cVJuwVXE7P5+GVULpockpelKQJV53V5Cx6UevCi4+eDqOZYl/TyTSHbCFgsXldj8ICvHOdtBwHxB2P9fSU58vl2zuUWNWrMU0mNBCAKz+7QFamzkvrMKEFSWzK4szXiFYyxpiBo3jhqLH/gCgL+GCe+DwfyCBHXeO2ieoxqoHCTBLXXbyjxu+hpzOCPoLwIj2VAIkr5PB1G8e1Ht0yYHNt2nKgk8M1OA2sLRn41IvRL9D3SheDISiisvdT2vWu26ReKomG/Yn/UqqiHr1iiLIJVN1xFkHg2GWQ2Ngz1ff9wDVAXsp0iMH3WiXc38ozo6ykb0yjsarRBYyf1IxnMhHfr9YtWjb8c/fWfrIEHMPZYf/CGLqttRbsENxZHtCGJMAlM6/A16SgXtkzxXGy+kEn0m+Zw2qA6OhDTFN27WJxlt2vSMsaBQGwBx3vxucUtOMHuthi3S05C7ErnBikC0Qy5wjQMoGng7bjWBPUS97+oLIFMAWDTHx7yLpX87tDFfd8V4eFKDx2y/POrHx+xBcCfflI4sZF98vYOeoRMPNdtTPOMW9REAPoKoZZaPhj/P454uYP7/akksFC7rYMNJH6pC6U3kLt67P62Z9a2SQPIqrfT8etfXCrxP2rZWUTst3myVLE5yKBJhZYUzcLiZMsCFhn+N8o0ZIo13TiDw8lqV6BcD0+aveLVIN3DXlLqs0lsN22HdmIfvevLb7xurlN+CwLDVqcRGNwGWGK8mLqmT/XxIqld3alCGST/Rg9ciFiCVajrEFbRH0JMUXhIqspgdIjesF8imOWEMIIFZQYJKoZIhvcNAQcBoIIFVgSCBVIwggVOMIIFSgYLKoZIhvcNAQwKAQKgggU5MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQ3y1iL21yckfUspImVh5rMwICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEEdACKY4qzn6EkAIZjzdxr8EggTQWos4Zqt9K0Q9eV1xdOT+1ycWlaA+p/3WksA5K7V/SHwjNnIX55nnix57UHNrHhnd2JRNW3GXunkEsgytAA1J36M7M7fwn0EDIiGEGNbdwvrLdOrBf/uIe5dAiB4nUZDi1PAUKJRiPolfL1MHL5BzV8Zwo5yikRHGdt/FloQh6o5emh6L0plkhfF8Bv4cFDET+ABo8mPy1wYIsCViyYXdsWimBuHxGmunH2uJ0EHbA3CwRFptscs3dRyShrBEahqsxzjrsi/PTeOsKkB4lEMhqdkAYpZ9M/dEFtYUD/H6PYz1dQkCTH7PzIkwKzllXLdEtwjvfMB/tEgbLCzshfE7UXHx71QRQr11mE4su36645p7uvBjXH+X5ucCvflwXEdBaTWL2Q7HwhuvVINgF1SqNsZA7YHcwO9oXaik0uZWLnGi3KmnqYzFVBZcZXbu1ldM4Wk7LqLfHo+GiD7d1sLktsTyLw5M/bv3Zo5QfmQU0g1wltdNz1D0jfvqMnNi6GFmu6NUgrj58SOwkuUYH5QFeBcn5+cWOhg1O7VNIZEfTJ1eKEWlozBKJyD/1uqRP04k43GDSRRozv9g89zMz56AbVuP8S1UxzrD2AVmdTJ4wFWbYpSrMbRXoFgv2b/F+Y8LFEmbG+CvwN4Kni8c0ZejWA6BysxrSK9pog4TcnUVrjPDSF5fYpJOJnx5/qjHYa5K5os9R8kHOogvZ1GYNGPAK9gDqpu3YMJzmFpZAIrc6i8un3Bc6m4DL7prYekKnDuVHZN+Q0K9FtHTsDcPZiXyLhlNmPfYf/ZFA0voIUFXfoEuI4lGztMigpcg0Yd01BpLrLyGztL48ud4h7Dk5IClWEuDhGmnLiP+DWXE9Sh9W48IQpCLsuoryERa/4cvmlrvfqdKv7H2xP+HS30YNeapB5XVlwuLkZepDIjym6QSKZSOZTYzddZOEnLmkryUlouiVpfiTf7FNBZ3otx2jpkhKpRhuv/pqHsvmFKvr8h9tfUP89AuAXooO4fI/YfAXmgl7Xc8RLfBbPLCpMufDEk3TsOQn836+hK6XJnaSQikb0HaUmyuZXaT2sHVNYdt9WlbPU85SVz4pWImVf+My1KD/TkHndubn0aRJDQsb/bi0sQx4Nzw5qTnfDjH7nBt7o7BodmkROWSyWUzE53S4H6jNoKRaVWVXyRFwiHmx/62nHS/VW/6fdXVvV2TLGu0yG5EEORJrC7poAhO5pxi/dMgM74k5Q4QnzHblZJTJ6m/I39LXF+gEwUTsmk7O3uK8yKMfbbhpv8bmT8wNQOIWECA/SvytpOdGLvf05Bhim/Ud4o7m0AzRhr3LPEFVp9A46r5jTr08C2sEkRM/fbuUXc0F5seR/EEiff7FW1mHa6KoK2W6tdvH3gGvfVaxMrtu/6aab1A5bw0WTLxq5D9ug6BflGFmkFqZCa/tsW81hxeDz+v3ZV9sO4cI41vcyPUigTEee2R336IDRUHTyQu2XYJ9MmMMLe9A13ZNGg2H43UmDJcOkvxLjxmovMpHgS8mhPSnUAKmD19ll95xhmIz04+xEGGbGVEXCR6RTzh7LXT2279i9xoXX7lN62ycb0bOFOgwmm/pxMIT53ZOueqeBOMSXbP6LzGZpI5U5cLGzf1Vv8wSTAxMA0GCWCGSAFlAwQCAQUABCDZFOldtGCTuJq9OVOZzkbk4oYRRYAgbDGG7g7ULprWTgQQn0bo65DpCVeQcOFKaQgeoAICCAA= diff --git a/tests/fixtures/keys/xmlsec/mixed-keys.xml b/tests/fixtures/keys/xmlsec/mixed-keys.xml new file mode 100644 index 00000000..0bda1d79 --- /dev/null +++ b/tests/fixtures/keys/xmlsec/mixed-keys.xml @@ -0,0 +1,52 @@ + + + + +test-hmac-sha1 +c2VjcmV0 + + +test-dsa + +

+4jl6DkcmDDBt815kg/WbxW1gnLtqH+kdjqEeFDD9m6EqGqvVhFbbvNNQqAwuaiJU +nWlR8gG47GtHKFN6w8CM1qteIo3foK504otZFNsl1p3cInQpdRCp2e/lQ+E24J/H +/n4Ix9pBNV63JIiSIqa+GpDuBpW4o3rrBRxTjOwYpWk= +

+9WQwByMPy0u1C8e2SeNQTvkG6tM= + +Rrg7e8pNLHMFK0pGW7xvzb7Kh6icJSsiBaX6aHqaQc9rSzzMJG3snBuQricNaUH5 +8ipucT+hdPRTo6g0ty5noyyBmqUvYHf9NuskQhPDmC3uTtqQTHeCEuX8XoH3YYlB +uE4nXvQRGZoyy+43ISe9aDnEAgIUVQXEayTVppRF24I= + +S3Gt9BE+wZb996U6h4nSNtYxEmE= + +WT0+1bR+bj65u5iDJ0MRc6/8iEAbvj7l5sAVn/H+SdZy94wW5mnSLCC5ufN33QPp +WNvgVk2igM+W51WlhFDgA8Xz9lRPk19jW8BXQpqv11MKoIBpaSAWvnhs/0AKubiT +XxJz7i78ZJy4hVTn99Rvt6Tc16/LICZfsqIJr+VK4Sg= + +
+
+ +test-rsa + + +0rGgazIyv0XjPXGGBwt1wvfCPO++VAlxW15LFinbxCeBkq/5jb/71gC7R2CJtUK4 +y/tIi7g89YBwQosJpgMMZt69fz51omEv/WobD0vUFcbRxek+Yi23ZHxhZMtO42Re +zfpwgC4ep0fXL+V105BUmjGFYACnUJdtMkG8ahH8/Zs= + +Aw== + + + +test-aes128 +0Xfy3ES+Fbv/OfWuQHKvPA== + + +test-camellia128 +0Xfy3ES+Fbv/OfWuQHKvPA== + +
diff --git a/tests/fixtures_smoke.rs b/tests/fixtures_smoke.rs index 21ba40ef..adc3e3f9 100644 --- a/tests/fixtures_smoke.rs +++ b/tests/fixtures_smoke.rs @@ -193,7 +193,7 @@ fn c14n11_xml_base_input_present() { #[test] fn fixture_file_count_matches_expected() { let expected = [ - ("keys", 28), + ("keys", 32), ("c14n", 41), ("xmldsig", 207), ("saml", 2), diff --git a/tests/key_manager_feature_contract.rs b/tests/key_manager_feature_contract.rs new file mode 100644 index 00000000..37791980 --- /dev/null +++ b/tests/key_manager_feature_contract.rs @@ -0,0 +1,11 @@ +#![cfg(feature = "xmlenc")] + +use xml_sec::key_manager::KeyInventory; + +#[test] +fn xmlenc_feature_exposes_key_inventory() { + // A consumer selecting the XML Encryption feature can compile the shared + // inventory API without separately naming the XMLDSig feature. + let inventory = KeyInventory::default(); + assert_eq!(inventory.entry_count(), 0); +} diff --git a/tests/xmlenc_encrypt_xmlsec1.rs b/tests/xmlenc_encrypt_xmlsec1.rs index c1bba2d2..65f31343 100644 --- a/tests/xmlenc_encrypt_xmlsec1.rs +++ b/tests/xmlenc_encrypt_xmlsec1.rs @@ -17,6 +17,7 @@ use xml_sec::xmlenc::{ DataEncryptionAlgorithm, EncryptedDataBuilder, EncryptionRecipient, OaepDigestAlgorithm, RsaOaepParameters, }; +use xml_sec::{key_manager::KeyInventory, policy::ResourcePolicy}; static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -152,3 +153,42 @@ fn xmlsec1_decrypts_rsa_oaep_wrapped_aes_cbc_from_xml_sec() { plaintext ); } + +#[test] +fn xmlsec1_decrypts_rsa_recipient_imported_by_key_inventory() { + // A caller-owned inventory, rather than a directly decoded RSA fixture, + // must preserve the independent libxmlsec1 transport wire contract. + if !xmlsec1::is_available() { + eprintln!("{}", xmlsec1::skip_reason()); + return; + } + let public_path = Path::new("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"); + let private_path = Path::new("tests/fixtures/keys/rsa/rsa-2048-key.pem"); + let public_pem = fs::read(public_path).expect("public-key fixture must load"); + let mut keys = KeyInventory::default(); + keys.add_public_pem( + "inventory-rsa".into(), + &public_pem, + &ResourcePolicy::default(), + ) + .expect("named public key must import"); + let public = keys + .rsa_encryption_key( + "inventory-rsa", + &xml_sec::policy::EncryptionPolicy::default(), + ) + .expect("imported RSA key must be usable for encryption"); + let plaintext = b"inventory-backed xmlsec1 interoperability"; + let encrypted = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .add_recipient(EncryptionRecipient::rsa_oaep(public).key_name("inventory-rsa")) + .encrypt_binary(plaintext) + .expect("inventory-backed encryption must succeed"); + assert_eq!( + decrypt_with_xmlsec1( + &encrypted.encrypted_data_xml, + "--privkey-pem:inventory-rsa", + private_path + ), + plaintext + ); +} diff --git a/tools/xmlsec1/src/args.rs b/tools/xmlsec1/src/args.rs index 2161b233..fafc0d86 100644 --- a/tools/xmlsec1/src/args.rs +++ b/tools/xmlsec1/src/args.rs @@ -210,6 +210,7 @@ pub(crate) const OPTION_SPECS: &[OptionSpec] = &[ option_spec!("privkey-der", [], VALUE, true, MULTIPLE), option_spec!("pkcs8-pem", ["privkey-p8-pem"], VALUE, true, MULTIPLE), option_spec!("pkcs8-der", ["privkey-p8-der"], VALUE, true, MULTIPLE), + option_spec!("pkcs12", [], VALUE, true, MULTIPLE), option_spec!("pubkey-pem", ["pubkey"], VALUE, true, MULTIPLE), option_spec!("pubkey-der", [], VALUE, true, MULTIPLE), option_spec!("pubkey-cert-pem", ["pubkey-cert"], VALUE, true, MULTIPLE), diff --git a/tools/xmlsec1/src/commands.rs b/tools/xmlsec1/src/commands.rs index 2d1b1d1c..9eafd5c2 100644 --- a/tools/xmlsec1/src/commands.rs +++ b/tools/xmlsec1/src/commands.rs @@ -16,6 +16,7 @@ use x509_parser::prelude::FromDer as _; use xml_sec::xml_input as xml_sec_xml_input; use xml_sec::{ IdAttributeRegistration, XmlBackend, + key_manager::{self, KeyInventory, SymmetricKeyKind}, policy::{ DecryptionPolicy, EcdsaSignatureValueEncoding, EncryptionPolicy, HmacPolicy, ManifestProcessing, ResourcePolicy, SameDocumentIdSemantics, SigningPolicy, @@ -69,7 +70,9 @@ const SIGN_OPTIONS: &[&str] = &[ "privkey-der", "pkcs8-pem", "pkcs8-der", + "pkcs12", "hmac-key", + "keys-file", "pwd", "lax-key-search", "node-id", @@ -89,6 +92,7 @@ const VERIFY_OPTIONS: &[&str] = &[ "pubkey-cert-pem", "pubkey-cert-der", "hmac-key", + "keys-file", "trusted-pem", "trusted-der", "untrusted-pem", @@ -120,6 +124,7 @@ const ENCRYPT_OPTIONS: &[&str] = &[ "binary-data", "xml-data", "aes-key", + "keys-file", "pubkey-pem", "pubkey-der", "pubkey-cert-pem", @@ -137,10 +142,12 @@ const DECRYPT_OPTIONS: &[&str] = &[ "print-xml-debug", "output", "aes-key", + "keys-file", "privkey-pem", "privkey-der", "pkcs8-pem", "pkcs8-der", + "pkcs12", "pwd", "lax-key-search", "node-id", @@ -194,6 +201,8 @@ pub enum CommandError { ExternalMaterialTooLarge { maximum: usize }, #[error(transparent)] Key(#[from] key_material::KeyMaterialError), + #[error(transparent)] + KeyStore(#[from] key_manager::KeyStoreError), #[error("XML signature operation failed: {0}")] Signature(String), #[error("signature is invalid")] @@ -695,6 +704,68 @@ fn named_candidate_search<'a, T: Copy>( ) } +fn load_xml_key_stores( + invocation: &Invocation, + policy: &P, + backend: XmlBackend, + budget: &mut ExternalMaterialBudget, +) -> Result { + let resources = policy.resource_policy(); + let mut all = KeyInventory::default(); + for option in invocation.values("keys-file") { + let path = Path::new(option.value.as_deref().unwrap_or_default()); + let bytes = read_key_material_with_budget(path, budget)?; + let store = KeyInventory::from_xml_bytes(&bytes, policy, backend)?; + all.extend(store, resources)?; + } + Ok(all) +} + +fn select_store_candidates<'a, T>( + entries: impl Iterator, + requested_names: &[String], + lax: bool, + max_candidates: usize, + name: impl Fn(&T) -> &str, +) -> Result, CommandError> { + // Policy caps candidates per stage, not the sum of selection and crypto + // attempts. Bound this scan independently before materializing matches. + let mut named = Vec::new(); + let mut fallback = Vec::new(); + for (inspected, entry) in entries.enumerate() { + if inspected == max_candidates { + return Err(CommandError::KeyStore(key_manager::KeyStoreError::Policy( + xml_sec::policy::PolicyViolation::ResourceLimit { + resource: "key candidates", + maximum: max_candidates, + actual: inspected.saturating_add(1), + }, + ))); + } + if requested_names.is_empty() + || requested_names + .iter() + .any(|requested| requested == name(entry)) + { + named.push(entry); + } else if lax { + fallback.push(entry); + } + } + if !lax && named.len() > 1 { + return Err(CommandError::Usage( + "multiple matching keys in --keys-file".into(), + )); + } + if lax { + named.extend(fallback); + } + if named.is_empty() { + return Err(CommandError::Usage("no matching key in --keys-file".into())); + } + Ok(named) +} + fn sign(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), CommandError> { validate_options(invocation, SIGN_OPTIONS)?; let xml_backend = selected_xml_backend(invocation)?; @@ -713,14 +784,82 @@ fn sign(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), CommandEr &policy, xml_backend, )?; - let selected = select_signing_key( - invocation, - &signature.key_names, - signature.algorithm, - signature.key_info.as_ref(), - &policy, - password, - )?; + let has_key_store = invocation.values("keys-file").next().is_some(); + if has_key_store + && invocation + .ordered_values(&[ + "hmac-key", + "privkey-pem", + "privkey-der", + "pkcs8-pem", + "pkcs8-der", + "pkcs12", + ]) + .next() + .is_some() + { + return Err(CommandError::Usage( + "sign cannot combine --keys-file with explicit key options".into(), + )); + } + let selected = if has_key_store { + if signature.key_names.is_empty() && !invocation.flag("lax-key-search") { + return Err(CommandError::Usage( + "sign with --keys-file requires a template KeyName unless --lax-key-search is set" + .into(), + )); + } + let mut budget = + ExternalMaterialBudget::new(policy.resources.max_external_resource_total_bytes); + let store = load_xml_key_stores(invocation, &policy, xml_backend, &mut budget)?; + let lax_candidates = invocation.flag("lax-key-search"); + let candidates = if signature.algorithm.hmac_output_bits().is_some() { + select_store_candidates( + store.symmetric_keys().iter().filter(|entry| { + entry.kind == SymmetricKeyKind::Hmac + && entry.usages.allows(key_manager::KeyUsage::Sign) + }), + &signature.key_names, + lax_candidates, + policy.resources.max_key_candidates, + |entry| &entry.name, + )? + .into_iter() + .map(|entry| entry.name.as_str()) + .collect::>() + } else { + select_store_candidates( + store + .private_keys() + .iter() + .filter(|entry| entry.usages.allows(key_manager::KeyUsage::Sign)), + &signature.key_names, + lax_candidates, + policy.resources.max_key_candidates, + |entry| &entry.name, + )? + .into_iter() + .map(|entry| entry.name.as_str()) + .collect::>() + }; + select_store_signing_key( + &store, + candidates, + signature.algorithm, + signature.key_info.as_ref(), + &policy, + lax_candidates, + )? + } else { + select_signing_key( + invocation, + &signature.key_names, + signature.algorithm, + signature.key_info.as_ref(), + &policy, + password, + )? + }; let mut context = SignContext::new(selected.key.as_ref()) .policy(policy) .xml_backend(xml_backend) @@ -801,6 +940,40 @@ struct SigningKeyCandidate { leaf_certificate_der: Option>, } +fn select_store_signing_key<'a>( + store: &KeyInventory, + candidates: impl IntoIterator, + algorithm: SignatureAlgorithm, + key_info: Option<&KeyInfo>, + policy: &SigningPolicy, + lax: bool, +) -> Result { + let mut last_error = None; + let mut lookup_budget = key_manager::SigningLookupBudget::default(); + for name in candidates { + let attempt = store + .signing_key_with_budget(name, algorithm, policy, &mut lookup_budget) + .map_err(CommandError::from) + .and_then(|key| { + let candidate = SigningKeyCandidate { + key, + certificate_writer: None, + leaf_certificate_der: None, + }; + validate_signing_key_info(key_info, &candidate)?; + Ok(candidate) + }); + match attempt { + Ok(candidate) => return Ok(candidate), + Err(error) if lax && lax_candidate_error_is_recoverable(&error) => { + last_error = Some(error); + } + Err(error) => return Err(error), + } + } + Err(last_error.unwrap_or_else(|| CommandError::Usage("no compatible signing key".into()))) +} + fn select_signing_key( invocation: &Invocation, requested_names: &[String], @@ -813,7 +986,13 @@ fn select_signing_key( let key_options: &[&str] = if hmac { &["hmac-key"] } else { - &["privkey-pem", "privkey-der", "pkcs8-pem", "pkcs8-der"] + &[ + "privkey-pem", + "privkey-der", + "pkcs8-pem", + "pkcs8-der", + "pkcs12", + ] }; let key_kind = if hmac { "HMAC key" } else { "private key" }; let keys = invocation @@ -824,7 +1003,7 @@ fn select_signing_key( return Err(CommandError::Usage(if hmac { "HMAC signing requires --hmac-key".into() } else { - "sign requires --privkey-pem or --pkcs8-pem/der".into() + "sign requires --privkey-pem, --pkcs8-pem/der, or --pkcs12".into() })); } let candidates = named_candidate_search( @@ -891,17 +1070,73 @@ fn prepare_signing_key_candidate( leaf_certificate_der: None, }); } + if option.name == "pkcs12" { + let path = Path::new(option.value.as_deref().unwrap_or_default()); + let bytes = read_key_material_with_budget(path, material_budget)?; + let password = password + .and_then(|value| std::str::from_utf8(value).ok()) + .ok_or(key_manager::KeyStoreError::ProtectedContainer)?; + let mut inventory = KeyInventory::default(); + let name = option.parameter.clone().unwrap_or_else(|| "pkcs12".into()); + inventory.add_pkcs12(name.clone(), &bytes, password, &policy.resources)?; + let key = inventory.signing_key(&name, algorithm, policy)?; + let imported = inventory + .private_keys() + .first() + .ok_or_else(|| CommandError::Usage("PKCS#12 contains no usable private key".into()))?; + let certificate_writer = imported + .matching_certificate_chain() + .map(X509CertificateKeyInfoWriter::from_der_chain) + .transpose() + .map_err(|error| CommandError::Signature(error.to_string()))?; + if let Some(writer) = &certificate_writer { + writer + .write_key_info(key.as_ref()) + .map_err(|error| CommandError::Signature(error.to_string()))?; + } + return Ok(SigningKeyCandidate { + key, + certificate_writer, + leaf_certificate_der: imported + .matching_certificate_chain() + .and_then(|chain| chain.first()) + .cloned(), + }); + } let (path, certificate_paths) = split_key_and_certificates(option.value.as_deref().unwrap_or_default())?; let key_bytes = key_material::read(path)?; material_budget.charge(key_bytes.len())?; - let key = key_material::decode_signing_key( - Path::new(path), - &key_bytes, - private_key_format(option), - algorithm, - password, - )?; + let format = private_key_format(option); + let key = if key_material::is_encrypted_pkcs8_container(&key_bytes, format) { + // All protected PKCS#8 aliases share the inventory's pre-decryption KDF gate; + // selecting a CLI spelling must never change import policy enforcement. + let mut inventory = KeyInventory::default(); + let name = option.parameter.as_deref().unwrap_or("explicit"); + match format { + key_material::PrivateKeyFormat::Pem | key_material::PrivateKeyFormat::Pkcs8Pem => { + inventory.add_private_pem( + name.into(), + &key_bytes, + password, + key_manager::KeyUsages::SIGN, + &policy.resources, + )?; + } + key_material::PrivateKeyFormat::Der | key_material::PrivateKeyFormat::Pkcs8Der => { + inventory.add_private_der( + name.into(), + &key_bytes, + password, + key_manager::KeyUsages::SIGN, + &policy.resources, + )?; + } + } + inventory.signing_key(name, algorithm, policy)? + } else { + key_material::decode_signing_key(Path::new(path), &key_bytes, format, algorithm, password)? + }; validate_signing_key(key.as_ref(), algorithm, policy) .map_err(|error| CommandError::Signature(error.to_string()))?; let (certificate_writer, leaf_certificate_der) = if certificate_paths.is_empty() { @@ -1174,7 +1409,13 @@ fn verify(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Command // With an explicit public key there is no key-manager search to relax. // Reject the flag on resolver-backed paths until its semantics exist. let lax_key_search = invocation.flag("lax-key-search"); - if lax_key_search && explicit_keys.is_empty() { + let has_key_store = invocation.values("keys-file").next().is_some(); + if has_key_store && !explicit_keys.is_empty() { + return Err(CommandError::Usage( + "verify cannot combine --keys-file with explicit key options".into(), + )); + } + if lax_key_search && explicit_keys.is_empty() && !has_key_store { return Err(CommandError::UnsupportedOption("lax-key-search".into())); } let policy = xmlsec_compatibility_verification_policy(invocation); @@ -1182,7 +1423,7 @@ fn verify(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Command let start_node_id = option_text(invocation, "node-id")?; let id_attributes = id_attribute_registrations(invocation)?; let key_name_resolution = if lax_key_search - || explicit_keys.is_empty() + || (explicit_keys.is_empty() && !has_key_store) || matches!(explicit_keys.as_slice(), [(key, _)] if key.parameter.is_none()) { key_material::VerificationKeyNameResolution::IgnoreDocumentKeyInfo @@ -1218,6 +1459,8 @@ fn verify(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Command selected_keys.is_empty(), &mut certificate_budget, )?; + let stored_keys = + load_xml_key_stores(invocation, &policy, xml_backend, &mut certificate_budget)?; let result = if !selected_keys.is_empty() { let mut candidates = Vec::with_capacity(selected_keys.len()); let mut last_load_error = None; @@ -1270,6 +1513,65 @@ fn verify(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Command .key_resolver(&resolver) .verify(&xml) .map_err(|error| CommandError::Signature(error.to_string()))? + } else if has_key_store && algorithm.hmac_output_bits().is_some() { + let selected = select_store_candidates( + stored_keys.symmetric_keys().iter().filter(|entry| { + entry.kind == SymmetricKeyKind::Hmac + && entry.usages.allows(key_manager::KeyUsage::Verify) + }), + &signature.key_names, + lax_key_search, + policy.resources.max_key_candidates, + |entry| &entry.name, + )?; + KeyCandidateBudget::with_limit(policy.resources.max_key_candidates) + .consume(selected.len()) + .map_err(|error| CommandError::Signature(error.to_string()))?; + let candidates = selected + .into_iter() + .map(|entry| { + HmacVerificationKey::new(entry.bytes.to_vec()) + .map(ExplicitVerificationCandidate::Hmac) + .map_err(|error| CommandError::Signature(error.to_string())) + }) + .collect::, _>>()?; + let resolver = CandidateVerificationResolver::new( + candidates, + configured_certificates, + lax_key_search, + policy.key_trust.check_crls, + ); + verification_context(policy, start_node_id, &id_attributes, xml_backend) + .key_resolver(&resolver) + .verify(&xml) + .map_err(|error| CommandError::Signature(error.to_string()))? + } else if has_key_store { + let selected = select_store_candidates( + stored_keys + .public_keys() + .iter() + .filter(|entry| entry.usages.allows(key_manager::KeyUsage::Verify)), + &signature.key_names, + lax_key_search, + policy.resources.max_key_candidates, + |entry| &entry.name, + )?; + KeyCandidateBudget::with_limit(policy.resources.max_key_candidates) + .consume(selected.len()) + .map_err(|error| CommandError::Signature(error.to_string()))?; + let resolver = CandidateVerificationResolver::new( + selected + .into_iter() + .map(|entry| ExplicitVerificationCandidate::Certificate(entry.key_info.clone())) + .collect(), + configured_certificates, + lax_key_search, + policy.key_trust.check_crls, + ); + verification_context(policy, start_node_id, &id_attributes, xml_backend) + .key_resolver(&resolver) + .verify(&xml) + .map_err(|error| CommandError::Signature(error.to_string()))? } else { let config = configured_certificates.into_resolver_config(); let resolver = DefaultKeyResolver::new(config); @@ -1363,12 +1665,46 @@ impl ExternalMaterialBudget { })?; Ok(()) } + + fn remaining(&self) -> usize { + self.maximum_bytes - self.total_bytes + } +} + +fn read_key_material_with_budget( + path: &Path, + budget: &mut ExternalMaterialBudget, +) -> Result, CommandError> { + let remaining = budget.remaining(); + let bytes = key_material::read_with_limit(path, remaining).map_err(|error| { + if remaining < key_material::KEY_MATERIAL_BYTE_CEILING + && matches!( + error, + key_material::KeyMaterialError::KeyMaterialTooLarge { .. } + ) + { + CommandError::ExternalMaterialTooLarge { + maximum: budget.maximum_bytes, + } + } else { + error.into() + } + })?; + budget.charge(bytes.len())?; + Ok(bytes) } fn lax_candidate_error_is_recoverable(error: &CommandError) -> bool { - // Lax lookup may skip an unusable candidate, but an invocation-wide - // resource ceiling is terminal rather than a property of that candidate. - !matches!(error, CommandError::ExternalMaterialTooLarge { .. }) + // Lax lookup may skip an unusable candidate, not an invocation-wide + // resource failure or a failed protected-container authentication. + !matches!( + error, + CommandError::ExternalMaterialTooLarge { .. } + | CommandError::KeyStore(key_manager::KeyStoreError::ProtectedContainer) + | CommandError::KeyStore(key_manager::KeyStoreError::Policy(_)) + | CommandError::Key(key_material::KeyMaterialError::ProtectedContainer) + | CommandError::Key(key_material::KeyMaterialError::Policy(_)) + ) } fn push_configured_certificate(certificates: &mut Vec>, certificate: Vec) { @@ -1783,6 +2119,12 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman (option, certificate) }) .collect::>(); + let has_key_store = invocation.values("keys-file").next().is_some(); + if has_key_store && (!aes_keys.is_empty() || !public_keys.is_empty()) { + return Err(CommandError::Usage( + "encrypt cannot combine --keys-file with explicit key options".into(), + )); + } if !aes_keys.is_empty() && !public_keys.is_empty() { return Err(CommandError::Usage( "encrypt cannot combine explicit AES and RSA recipient keys".into(), @@ -1838,6 +2180,221 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman if let Some(name) = option.parameter.as_deref() { builder = builder.direct_key_name(name); } + } else if has_key_store && !metadata.has_encrypted_key_recipient { + let mut budget = + ExternalMaterialBudget::new(policy.resources.max_external_resource_total_bytes); + let store = load_xml_key_stores(invocation, &policy, xml_backend, &mut budget)?; + let requested_names = metadata + .content_key_name + .iter() + .cloned() + .collect::>(); + let candidates = select_store_candidates( + store.symmetric_keys().iter().filter(|entry| { + entry.kind == SymmetricKeyKind::Aes + && entry.usages.allows(key_manager::KeyUsage::Encrypt) + }), + &requested_names, + invocation.flag("lax-key-search"), + policy.resources.max_key_candidates, + |entry| &entry.name, + )?; + KeyCandidateBudget::with_limit(policy.resources.max_key_candidates) + .consume(candidates.len()) + .map_err(|error| CommandError::Encryption(error.to_string()))?; + let selected = candidates + .into_iter() + .find(|entry| entry.bytes.len() == algorithm.key_len()) + .ok_or_else(|| CommandError::Usage("no compatible AES key in --keys-file".into()))?; + let key = + key_material::decode_symmetric(selected.bytes.to_vec(), Some(algorithm.key_len()))?; + builder = builder.direct_key(key).direct_key_name(&selected.name); + } else if has_key_store { + let mut budget = + ExternalMaterialBudget::new(policy.resources.max_external_resource_total_bytes); + let store = load_xml_key_stores(invocation, &policy, xml_backend, &mut budget)?; + let template_recipients = if metadata.recipients.is_empty() { + vec![EncryptionTemplateRecipient { + key_name: None, + oaep_parameters: None, + }] + } else { + metadata.recipients + }; + KeyCandidateBudget::with_limit(policy.resources.max_key_candidates) + .consume(template_recipients.len()) + .map_err(|error| CommandError::Encryption(error.to_string()))?; + let recipient_metadata = recipient_key_metadata( + &template, + start_node_id, + &id_attributes, + &policy, + template_recipients.len(), + xml_backend, + )?; + let mut store_candidate_budget = + KeyCandidateBudget::with_limit(policy.resources.max_key_candidates); + let mut available_public_keys_by_name = HashMap::new(); + let mut only_public_key = None; + let mut public_key_count = 0; + for entry in store + .public_keys() + .iter() + .filter(|entry| entry.usages.allows(key_manager::KeyUsage::Encrypt)) + { + public_key_count += 1; + only_public_key = Some(entry); + available_public_keys_by_name.insert( + entry.name.as_str(), + AvailableStoreRecipient { + entry, + reservations: 0, + loaded: None, + }, + ); + } + let lax = invocation.flag("lax-key-search"); + let mut reserved_slots = Vec::new(); + if lax { + reserved_slots.reserve(template_recipients.len()); + // A stale name contradicted by recipient metadata is not an exact + // match. Cache decoded candidates so reservation checks do not + // repeat RSA decoding during assignment; names remain borrowed. + for (recipient, metadata) in template_recipients.iter().zip(&recipient_metadata) { + let mut reserved = false; + if let Some(name) = recipient.key_name.as_deref() + && let Some(available) = available_public_keys_by_name.get_mut(name) + { + if metadata.as_ref().is_some_and(|metadata| { + metadata + .0 + .sources + .iter() + .any(|source| !matches!(source, KeyInfoSource::KeyName(_))) + }) { + if available.loaded.is_none() { + store_candidate_budget + .consume(1) + .map_err(|error| CommandError::Encryption(error.to_string()))?; + match load_stored_recipient_candidate(available.entry, &policy) { + Ok(candidate) => available.loaded = Some(candidate), + Err( + error @ CommandError::KeyStore( + key_manager::KeyStoreError::Policy(_), + ), + ) => return Err(error), + Err(_) => {} + } + } + reserved = available.loaded.as_ref().is_some_and(|candidate| { + validate_recipient_key_metadata(metadata.as_ref(), candidate).is_ok() + }); + } else { + reserved = true; + } + if reserved { + available.reservations += 1; + } + } + reserved_slots.push(reserved); + } + } + for (slot, (recipient, metadata)) in template_recipients + .into_iter() + .zip(recipient_metadata) + .enumerate() + { + if lax + && reserved_slots[slot] + && let Some(name) = recipient.key_name.as_deref() + && let Some(available) = available_public_keys_by_name.get_mut(name) + { + available.reservations -= 1; + } + let exact = match recipient.key_name.as_deref() { + Some(name) => available_public_keys_by_name + .get(name) + .map(|available| available.entry), + None if public_key_count == 1 => only_public_key.and_then(|entry| { + available_public_keys_by_name + .get(entry.name.as_str()) + .filter(|available| !lax || available.reservations == 0) + .map(|available| available.entry) + }), + None if !lax && public_key_count > 1 => { + return Err(CommandError::Usage( + "multiple matching keys in --keys-file".into(), + )); + } + None => None, + }; + if exact.is_none() && !lax { + return Err(CommandError::Usage("no matching key in --keys-file".into())); + } + let fallbacks = store.public_keys().iter().filter(|entry| { + lax && entry.usages.allows(key_manager::KeyUsage::Encrypt) + && !exact.is_some_and(|selected| std::ptr::eq(selected, *entry)) + }); + let mut selected = None; + let mut last_error = None; + for entry in exact.into_iter().chain(fallbacks) { + if !exact.is_some_and(|selected| std::ptr::eq(selected, entry)) + && !available_public_keys_by_name + .get(entry.name.as_str()) + .is_some_and(|available| available.reservations == 0) + { + continue; + } + let cached = available_public_keys_by_name + .get_mut(entry.name.as_str()) + .and_then(|available| available.loaded.take()); + // Reservation already charged decoding for a retained candidate. + // Charge new inspections before work, not movement out of the cache. + let candidate = match cached { + Some(candidate) => Ok(candidate), + None => { + store_candidate_budget + .consume(1) + .map_err(|error| CommandError::Encryption(error.to_string()))?; + load_stored_recipient_candidate(entry, &policy) + } + } + .and_then(|candidate| { + // This exact slot was checked against immutable metadata + // before reservation. Do not repeat its conversions. + if !(lax + && reserved_slots[slot] + && exact.is_some_and(|selected| std::ptr::eq(selected, entry))) + { + validate_recipient_key_metadata(metadata.as_ref(), &candidate)?; + } + Ok(candidate) + }); + match candidate { + Ok(candidate) => { + selected = Some((entry, candidate)); + break; + } + Err(error) => last_error = Some(error), + } + } + let (entry, candidate) = selected.ok_or_else(|| { + last_error.unwrap_or_else(|| { + CommandError::Usage("no compatible RSA key in --keys-file".into()) + }) + })?; + // Lax recipient search assigns each available entry once, as the + // explicit-key path does. Keep store order for subsequent fallbacks. + if lax { + available_public_keys_by_name.remove(entry.name.as_str()); + } + let mut configured = + EncryptionRecipient::rsa_oaep(candidate.public_key).key_name(&entry.name); + if let Some(parameters) = recipient.oaep_parameters { + configured = configured.oaep_parameters(parameters); + } + builder = builder.add_recipient(configured); + } } else if !public_keys.is_empty() { let template_recipients = if metadata.recipients.is_empty() { vec![EncryptionTemplateRecipient { @@ -2118,6 +2675,25 @@ struct RecipientPublicKeyCandidate { certificate_der: Option>, } +struct AvailableStoreRecipient<'a> { + entry: &'a key_manager::StoredPublicKey, + reservations: usize, + loaded: Option, +} + +fn load_stored_recipient_candidate( + entry: &key_manager::StoredPublicKey, + policy: &EncryptionPolicy, +) -> Result { + let public_key = entry.rsa_encryption_key(policy)?; + validate_rsa_recipient_key(&public_key, policy) + .map_err(|error| CommandError::Encryption(error.to_string()))?; + Ok(RecipientPublicKeyCandidate { + public_key, + certificate_der: None, + }) +} + #[derive(Clone, Copy)] enum RecipientPublicKeySource { Public(key_material::PublicKeyEncoding), @@ -2393,6 +2969,31 @@ fn direct_simple_text(node: Node<'_, '_>, field: &str) -> Result, + right: Node<'_, '_>, + field: &str, +) -> Result { + if left.children().any(|child| child.is_element()) + || right.children().any(|child| child.is_element()) + { + return Err(CommandError::Encryption(format!( + "{field} must not contain element children" + ))); + } + let left_bytes = left + .children() + .filter(Node::is_text) + .filter_map(|child| child.text()) + .flat_map(str::bytes); + let right_bytes = right + .children() + .filter(Node::is_text) + .filter_map(|child| child.text()) + .flat_map(str::bytes); + Ok(left_bytes.eq(right_bytes)) +} + fn oaep_digest_from_uri(uri: &str) -> Result { OaepDigestAlgorithm::from_uri(uri) .ok_or_else(|| CommandError::Encryption(format!("unsupported OAEP digest: {uri}"))) @@ -2449,6 +3050,17 @@ fn apply_encryption_template( let generated_key_info = direct_child_element(generated_data, XMLDSIG_NS, "KeyInfo"); match (template_key_info, generated_key_info) { (Some(template_key_info), Some(generated_key_info)) => { + if let (Some(template_name), Some(generated_name)) = ( + direct_child_element(template_key_info, XMLDSIG_NS, "KeyName"), + direct_child_element(generated_key_info, XMLDSIG_NS, "KeyName"), + ) && !same_direct_simple_text(template_name, generated_name, "KeyName")? + { + replacements.push(replace_element_text( + template, + template_name, + &escape_text(generated_name.text().unwrap_or_default()), + )?); + } let template_keys = direct_encrypted_keys(template_key_info); let generated_keys = direct_encrypted_keys(generated_key_info); let template_values = encrypted_key_cipher_values(template_key_info, "template")?; @@ -2600,8 +3212,13 @@ fn merge_generated_recipient_key_name( let key_name = standalone_element(generated, generated_key_name)?; if let Some(template_key_info) = direct_child_element(template_key, XMLDSIG_NS, "KeyInfo") { - if direct_child_element(template_key_info, XMLDSIG_NS, "KeyName").is_some() { - return Ok(None); + if let Some(template_key_name) = + direct_child_element(template_key_info, XMLDSIG_NS, "KeyName") + { + if same_direct_simple_text(template_key_name, generated_key_name, "KeyName")? { + return Ok(None); + } + return Ok(Some((template_key_name.range(), key_name))); } return append_element_children_replacement(template, template_key_info, &key_name) .map(Some); @@ -2796,9 +3413,7 @@ fn decrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman validate_options(invocation, DECRYPT_OPTIONS)?; let xml_backend = selected_xml_backend(invocation)?; validate_supported_selectors(invocation, &["node-id", "id-attr", "add-id-attr"])?; - if invocation.flag("pwd") { - return Err(CommandError::UnsupportedOption("pwd".into())); - } + let password = invocation.password_bytes(); let policy = DecryptionPolicy::default(); let xml = read_input(invocation, policy.resources.max_xml_document_bytes)?; let encrypted_data_id = option_text(invocation, "node-id")?; @@ -2810,8 +3425,20 @@ fn decrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman let recipient_key_names = encrypted_key_recipient_names(encrypted_data)?; let aes_keys = invocation.values("aes-key").collect::>(); let private_keys = invocation - .ordered_values(&["privkey-pem", "privkey-der", "pkcs8-pem", "pkcs8-der"]) + .ordered_values(&[ + "privkey-pem", + "privkey-der", + "pkcs8-pem", + "pkcs8-der", + "pkcs12", + ]) .collect::>(); + let has_key_store = invocation.values("keys-file").next().is_some(); + if has_key_store && (!aes_keys.is_empty() || !private_keys.is_empty()) { + return Err(CommandError::Usage( + "decrypt cannot combine --keys-file with explicit key options".into(), + )); + } if !aes_keys.is_empty() && !private_keys.is_empty() { return Err(CommandError::Usage( "decrypt cannot combine explicit AES and RSA private keys".into(), @@ -2839,7 +3466,7 @@ fn decrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman let mut last_error = None; for (option, ()) in candidates { match key_material::load_symmetric(option.value.as_deref().unwrap_or_default(), None) { - Ok(key) => keys.push(key), + Ok(key) => keys.push(std::borrow::Cow::Owned(key)), Err(error) if lax_key_search => last_error = Some(CommandError::from(error)), Err(error) => return Err(error.into()), } @@ -2857,6 +3484,49 @@ fn decrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman &id_attributes, xml_backend, )? + } else if has_key_store { + let mut budget = + ExternalMaterialBudget::new(policy.resources.max_external_resource_total_bytes); + let store = load_xml_key_stores(invocation, &policy, xml_backend, &mut budget)?; + if !recipient_key_names.is_empty() + && !store.symmetric_keys().iter().any(|entry| { + entry.kind == SymmetricKeyKind::Aes + && entry.usages.allows(key_manager::KeyUsage::Decrypt) + }) + { + return Err(CommandError::Usage( + "--keys-file does not supply RSA recipient private keys for decrypt".into(), + )); + } + let requested_names = content_key_name.iter().cloned().collect::>(); + let selected = select_store_candidates( + store.symmetric_keys().iter().filter(|entry| { + entry.kind == SymmetricKeyKind::Aes + && entry.usages.allows(key_manager::KeyUsage::Decrypt) + }), + &requested_names, + invocation.flag("lax-key-search"), + policy.resources.max_key_candidates, + |entry| &entry.name, + )?; + KeyCandidateBudget::with_limit(policy.resources.max_key_candidates) + .consume(selected.len()) + .map_err(|error| CommandError::Encryption(error.to_string()))?; + let resolver = CandidateSymmetricKeyDecryptor { + keys: selected + .into_iter() + .map(|entry| std::borrow::Cow::Borrowed(entry.bytes.as_slice())) + .collect(), + }; + decrypt_input( + &resolver, + &xml, + encrypted_data_id, + standalone, + policy, + &id_attributes, + xml_backend, + )? } else if !private_keys.is_empty() { let selected = select_recipient_private_keys( &private_keys, @@ -2873,14 +3543,40 @@ fn decrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman ExternalMaterialBudget::new(policy.resources.max_external_resource_total_bytes); for option in selected { let loaded = (|| { + if option.name == "pkcs12" { + let path = Path::new(option.value.as_deref().unwrap_or_default()); + let bytes = read_key_material_with_budget(path, &mut certificate_budget)?; + let password = password + .and_then(|value| std::str::from_utf8(value).ok()) + .ok_or(key_manager::KeyStoreError::ProtectedContainer)?; + let mut inventory = KeyInventory::default(); + let name = option.parameter.clone().unwrap_or_else(|| "pkcs12".into()); + inventory.add_pkcs12(name.clone(), &bytes, password, &policy.resources)?; + let imported = inventory.private_keys().first().ok_or_else(|| { + CommandError::Usage("PKCS#12 contains no usable private key".into()) + })?; + let private_key = key_material::decode_rsa_private_with_password( + path, + &imported.pkcs8_der, + key_material::PrivateKeyFormat::Pkcs8Der, + None, + &policy.resources, + )?; + return Ok(RecipientPrivateKey { + inner: PrivateKeyDecryptor::new(private_key), + key_name: option.parameter.clone(), + }); + } let (path, certificate_paths) = split_key_and_certificates(option.value.as_deref().unwrap_or_default())?; - let bytes = key_material::read(path)?; - certificate_budget.charge(bytes.len())?; - let private_key = key_material::decode_rsa_private( + let bytes = + read_key_material_with_budget(Path::new(path), &mut certificate_budget)?; + let private_key = key_material::decode_rsa_private_with_password( Path::new(path), &bytes, private_key_format(option), + password, + &policy.resources, )?; if !certificate_paths.is_empty() { let encoding = if matches!( @@ -2934,7 +3630,7 @@ fn decrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman )? } else { return Err(CommandError::Usage( - "decrypt requires --aes-key or an RSA private key".into(), + "decrypt requires --aes-key, an RSA private key, or --pkcs12".into(), )); }; write_result_then_stdout_diagnostics(invocation, &bytes, stdout, |stdout| { @@ -3007,18 +3703,21 @@ struct RecipientPrivateKey { key_name: Option, } -struct CandidateSymmetricKeyDecryptor { - keys: Vec>, +struct CandidateSymmetricKeyDecryptor<'a> { + keys: Vec>, } -impl DecryptionKeyResolver for CandidateSymmetricKeyDecryptor { +impl DecryptionKeyResolver for CandidateSymmetricKeyDecryptor<'_> { fn resolve_key( &self, _provider: &dyn CryptoProvider, _algorithm: DataEncryptionAlgorithm, _encrypted_key: Option<&EncryptedKey>, ) -> Result, XmlEncError> { - self.keys.first().cloned().ok_or(XmlEncError::KeyNotFound) + self.keys + .first() + .map(|key| key.as_ref().to_vec()) + .ok_or(XmlEncError::KeyNotFound) } fn resolve_key_candidates( @@ -3030,7 +3729,7 @@ impl DecryptionKeyResolver for CandidateSymmetricKeyDecryptor { ) -> Result>, XmlEncError> { if encrypted_key.is_none() { budget.consume(self.keys.len())?; - Ok(self.keys.clone()) + Ok(self.keys.iter().map(|key| key.as_ref().to_vec()).collect()) } else { Err(XmlEncError::KeyNotFound) } @@ -3517,12 +4216,108 @@ fn stdout_error(source: std::io::Error) -> CommandError { mod tests { use std::{cell::Cell, ffi::OsString, rc::Rc}; + use base64::Engine as _; + use super::*; fn invocation(arguments: &[&str]) -> Invocation { Invocation::parse(arguments.iter().map(OsString::from)).unwrap() } + #[test] + fn explicit_pkcs8_signing_enforces_import_kdf_limits() { + // Explicit PEM/DER options, including generic private-key aliases, must + // reject KDF policy violations before password-dependent decryption. + use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng as _}; + use rsa::pkcs8::{DecodePrivateKey as _, EncodePrivateKey as _}; + let rsa = rsa::RsaPrivateKey::from_pkcs8_pem(include_str!( + "../../../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .unwrap(); + let plain = rsa.to_pkcs8_der().unwrap(); + let encrypted = rsa::pkcs8::PrivateKeyInfoRef::try_from(plain.as_bytes()) + .unwrap() + .encrypt_with_rng(&mut ChaCha20Rng::seed_from_u64(42), b"correct") + .unwrap(); + let pem = encrypted + .to_pem("ENCRYPTED PRIVATE KEY", der::pem::LineEnding::LF) + .unwrap(); + let temp = tempfile::tempdir().unwrap(); + for option_name in ["pkcs8-pem", "pkcs8-der", "privkey-pem", "privkey-der"] { + let path = temp.path().join(option_name); + fs::write( + &path, + if option_name.ends_with("pem") { + pem.as_bytes() + } else { + encrypted.as_bytes() + }, + ) + .unwrap(); + let parsed = Invocation::parse([ + OsString::from("xmlsec1"), + OsString::from("sign"), + OsString::from(format!("--{option_name}")), + path.into_os_string(), + OsString::from("template.xml"), + ]) + .unwrap(); + for memory_limit in [false, true] { + let mut policy = SigningPolicy::default(); + if memory_limit { + policy.resources.max_key_import_kdf_memory_bytes = 1; + } else { + policy.resources.max_key_import_kdf_work = 1; + } + let mut budget = + ExternalMaterialBudget::new(policy.resources.max_external_resource_total_bytes); + let result = prepare_signing_key_candidate( + parsed.values(option_name).next().unwrap(), + SignatureAlgorithm::RsaSha256, + &policy, + Some(b"wrong"), + &mut budget, + ); + assert!( + matches!( + result, + Err(CommandError::KeyStore(key_manager::KeyStoreError::Policy( + _ + ))) + ), + "{option_name}, memory limit {memory_limit}" + ); + } + } + } + + #[test] + fn lax_key_search_stops_on_password_and_policy_failures() { + // Candidate search may skip incompatible keys, never terminal + // authentication or operation-wide policy failures. + assert!(!lax_candidate_error_is_recoverable(&CommandError::Key( + key_material::KeyMaterialError::ProtectedContainer, + ))); + assert!(!lax_candidate_error_is_recoverable( + &CommandError::KeyStore(key_manager::KeyStoreError::Policy( + xml_sec::policy::PolicyViolation::ResourceLimit { + resource: "external resource bytes", + maximum: 1, + actual: 2, + }, + ),) + )); + assert!(!lax_candidate_error_is_recoverable(&CommandError::Key( + key_material::KeyMaterialError::Policy( + xml_sec::policy::PolicyViolation::ResourceLimit { + resource: "external resource bytes", + maximum: 1, + actual: 2, + }, + ), + ))); + } + #[test] fn compatibility_signing_policy_includes_every_implemented_algorithm() { // An explicit allowlist replaces, rather than extends, secure defaults. @@ -3569,6 +4364,364 @@ mod tests { .join(name) } + #[test] + fn named_store_encryption_falls_back_only_when_lax() { + // An absent named key may fall back in lax mode, but an exact match wins. + let names = ["fallback", "exact"]; + let requested = vec!["exact".to_string()]; + assert_eq!( + select_store_candidates(names.iter(), &requested, true, 2, |name| name).unwrap()[0], + &"exact" + ); + let absent = vec!["absent".to_string()]; + assert!(select_store_candidates(names.iter(), &absent, false, 2, |name| name).is_err()); + assert_eq!( + select_store_candidates(names.iter(), &absent, true, 2, |name| name).unwrap()[0], + &"fallback" + ); + } + + #[test] + fn store_selection_bounds_inspected_candidates() { + // A name filter cannot make scanning an oversized candidate pool free. + let names = ["first", "second"]; + let requested = vec!["second".to_owned()]; + assert!(matches!( + select_store_candidates(names.iter(), &requested, false, 1, |name| name), + Err(CommandError::KeyStore(key_manager::KeyStoreError::Policy( + xml_sec::policy::PolicyViolation::ResourceLimit { + resource: "key candidates", + maximum: 1, + actual: 2, + } + ))) + )); + assert_eq!( + select_store_candidates(names.iter(), &requested, false, 2, |name| name).unwrap(), + vec![&"second"] + ); + } + + #[test] + fn cli_lax_store_encryption_accepts_missing_template_key_name() { + // Exercise the command boundary: a present but unknown KeyName must + // fall back only when --lax-key-search was explicitly requested. + let temp = tempfile::tempdir().expect("temporary test directory"); + let template_path = temp.path().join("template.xml"); + let input_path = temp.path().join("input.bin"); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/xmlenc/aleksey-xmlenc-01/enc-aes128gcm-keyname.tmpl"); + let template = fs::read_to_string(fixture) + .expect("encryption template fixture") + .replace("test-aes128", "absent"); + fs::write(&template_path, template).expect("write encryption template"); + fs::write(&input_path, b"lax fallback payload").expect("write plaintext"); + let store = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keys/xmlsec/mixed-keys.xml"); + let args = [ + "xmlsec1", + "encrypt", + "--keys-file", + store.to_str().expect("fixture path is UTF-8"), + "--binary-data", + input_path.to_str().expect("input path is UTF-8"), + template_path.to_str().expect("template path is UTF-8"), + ]; + assert!(execute(invocation(&args), &mut Vec::new(), &mut Vec::new()).is_err()); + let mut lax_args = vec!["xmlsec1", "encrypt", "--lax-key-search"]; + lax_args.extend_from_slice(&args[2..]); + let mut output = Vec::new(); + execute(invocation(&lax_args), &mut output, &mut Vec::new()) + .expect("lax store encryption finds alternate AES key"); + assert!(String::from_utf8_lossy(&output).contains("CipherValue")); + } + + #[test] + fn cli_lax_store_encryption_skips_ineligible_aes_key() { + // A fallback candidate with the wrong AES length must not hide a later usable key. + let temp = tempfile::tempdir().expect("temporary test directory"); + let template_path = temp.path().join("template.xml"); + let input_path = temp.path().join("input.bin"); + let store_path = temp.path().join("keys.xml"); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/xmlenc/aleksey-xmlenc-01/enc-aes128gcm-keyname.tmpl"); + let template = fs::read_to_string(fixture) + .expect("encryption template fixture") + .replace("test-aes128", "absent"); + fs::write(&template_path, template).expect("write encryption template"); + fs::write(&input_path, b"lax fallback payload").expect("write plaintext"); + let source = fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keys/xmlsec/mixed-keys.xml"), + ) + .expect("key store fixture"); + let extra = "wrong-aes192AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + fs::write( + &store_path, + source.replacen("lax RSA fallback payload").expect("write plaintext"); + let pem = fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"), + ) + .expect("public key fixture"); + let public_key = RsaPublicKey::from_public_key_pem(&pem).expect("RSA public key"); + let encode = |bytes: Vec| base64::engine::general_purpose::STANDARD.encode(bytes); + let extra = format!( + "valid-rsa{}{}", + encode(public_key.n().to_be_bytes_trimmed_vartime().into_vec()), + encode(public_key.e().to_be_bytes_trimmed_vartime().into_vec()) + ); + let source = fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keys/xmlsec/mixed-keys.xml"), + ) + .expect("key store fixture"); + fs::write( + &store_path, + source.replacen("
", &format!("{extra}
"), 1), + ) + .expect("write key store"); + let args = [ + "xmlsec1", + "encrypt", + "--lax-key-search", + "--keys-file", + store_path.to_str().expect("store path is UTF-8"), + "--xml-data", + input_path.to_str().expect("input path is UTF-8"), + template_path.to_str().expect("template path is UTF-8"), + ]; + let mut output = Vec::new(); + execute(invocation(&args), &mut output, &mut Vec::new()) + .expect("lax search skips RSA-1024 before RSA-2048"); + assert!(String::from_utf8_lossy(&output).contains("CipherValue")); + let encrypted = temp.path().join("encrypted.xml"); + fs::write(&encrypted, &output).expect("write encrypted output"); + let private = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keys/rsa/rsa-2048-key.pem"); + let decrypt_args = [ + "xmlsec1", + "decrypt", + "--privkey-pem:valid-rsa", + private.to_str().expect("key path is UTF-8"), + encrypted.to_str().expect("encrypted path is UTF-8"), + ]; + let mut decrypted = Vec::new(); + execute(invocation(&decrypt_args), &mut decrypted, &mut Vec::new()) + .expect("strict decryption uses the fallback recipient name"); + assert_eq!(decrypted, b"lax RSA fallback payload"); + } + + #[test] + fn store_signing_retries_key_info_mismatch_in_lax_mode() { + // An algorithm-compatible key is not a valid match for embedded KeyInfo. + let mut inventory = KeyInventory::default(); + let policy = SigningPolicy::default(); + let wrong = std::fs::read( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keys/rsa/rsa-2048-key.pem"), + ) + .unwrap(); + let right = std::fs::read( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keys/rsa/rsa-4096-key.pem"), + ) + .unwrap(); + for (name, pem) in [("wrong", wrong), ("right", right)] { + inventory + .add_private_pem( + name.into(), + &pem, + None, + key_manager::KeyUsages::SIGN, + &policy.resources, + ) + .unwrap(); + } + let right_spki = inventory + .signing_key("right", SignatureAlgorithm::RsaSha256, &policy) + .unwrap() + .public_key_info() + .unwrap() + .spki_der() + .unwrap() + .to_vec(); + let mut info = KeyInfo::default(); + info.sources + .push(KeyInfoSource::DerEncodedKeyValue(right_spki)); + assert!( + select_store_signing_key( + &inventory, + ["wrong"], + SignatureAlgorithm::RsaSha256, + Some(&info), + &policy, + false, + ) + .is_err() + ); + assert!( + select_store_signing_key( + &inventory, + ["wrong", "right"], + SignatureAlgorithm::RsaSha256, + Some(&info), + &policy, + true, + ) + .is_ok() + ); + } + + #[test] + fn lax_store_signing_shares_lookup_budget_across_retries() { + // Three independent scans cost 1 + 2 + 3 inspections, not three. + let mut inventory = KeyInventory::default(); + let mut policy = SigningPolicy::default(); + let wrong = fs::read( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keys/rsa/rsa-2048-key.pem"), + ) + .unwrap(); + let right = fs::read( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keys/rsa/rsa-4096-key.pem"), + ) + .unwrap(); + for (name, pem) in [("wrong-a", &wrong), ("wrong-b", &wrong), ("right", &right)] { + inventory + .add_private_pem( + name.into(), + pem, + None, + key_manager::KeyUsages::SIGN, + &policy.resources, + ) + .unwrap(); + } + let right_spki = inventory + .signing_key("right", SignatureAlgorithm::RsaSha256, &policy) + .unwrap() + .public_key_info() + .unwrap() + .spki_der() + .unwrap() + .to_vec(); + let mut info = KeyInfo::default(); + info.sources + .push(KeyInfoSource::DerEncodedKeyValue(right_spki)); + policy.resources.max_key_candidates = 3; + assert!( + select_store_signing_key( + &inventory, + ["wrong-a", "wrong-b", "right"], + SignatureAlgorithm::RsaSha256, + Some(&info), + &policy, + true, + ) + .is_err() + ); + } + + #[test] + fn strict_store_signing_requires_template_key_name() { + // A singleton store must not silently authorize an unnamed template. + let temp = tempfile::tempdir().expect("temporary signing template"); + let template = temp.path().join("unsigned.xml"); + fs::write( + &template, + br#""#, + ) + .expect("write template"); + let template = template.to_str().expect("UTF-8 path"); + let store = temp.path().join("keys.xml"); + fs::write( + &store, + br#"only-keyc2VjcmV0"#, + ) + .expect("write singleton store"); + let store = store.to_str().expect("UTF-8 path"); + let strict = invocation(&["xmlsec1", "sign", "--keys-file", store, template]); + let error = sign(&strict, &mut Vec::new()).expect_err("strict mode requires KeyName"); + assert!( + error.to_string().contains("requires a template KeyName"), + "{error}" + ); + let lax = invocation(&[ + "xmlsec1", + "sign", + "--lax-key-search", + "--keys-file", + store, + template, + ]); + let mut signed = Vec::new(); + sign(&lax, &mut signed).expect("lax mode may select the unnamed singleton"); + assert!(String::from_utf8_lossy(&signed).contains("DigestValue")); + } + + #[test] + fn pkcs12_signing_ignores_unrelated_ca_certificate() { + // A CA-only bundle still provides its private signing key, without a leaf writer. + let bundle = base64::engine::general_purpose::STANDARD + .decode( + include_str!("../../../tests/fixtures/keys/pkcs12/rsa-key-unrelated-ca.p12.b64") + .trim(), + ) + .unwrap(); + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("key.p12"); + fs::write(&path, bundle).unwrap(); + let parsed = Invocation::parse([ + OsString::from("xmlsec1"), + OsString::from("sign"), + OsString::from("--pkcs12"), + path.into_os_string(), + OsString::from("template.xml"), + ]) + .unwrap(); + let option = parsed.values("pkcs12").next().unwrap(); + let mut budget = ExternalMaterialBudget::new(usize::MAX); + let candidate = prepare_signing_key_candidate( + option, + SignatureAlgorithm::RsaSha256, + &SigningPolicy::default(), + Some(b"secret"), + &mut budget, + ) + .unwrap(); + assert!(candidate.certificate_writer.is_none()); + assert!(candidate.leaf_certificate_der.is_none()); + } + struct CountingVerificationKey { accepts: bool, calls: Rc>, @@ -3984,6 +5137,40 @@ mod tests { ); } + #[test] + fn generated_recipient_replaces_a_split_stale_key_name() { + // A comment may split direct KeyName text without changing its value. + // Comparing only the first text child would retain the stale name. + let template = format!( + "valid-old" + ); + let generated = format!( + "valida2V5" + ); + let template_doc = Document::parse(&template).expect("template parses"); + let generated_doc = Document::parse(&generated).expect("generated key parses"); + let replacement = merge_generated_recipient_key_name( + &template, + template_doc.root_element(), + &generated, + generated_doc.root_element(), + ) + .expect("recipient name merge succeeds") + .expect("the stale full name must be replaced"); + let rendered = format!( + "{}{}{}", + &template[..replacement.0.start], + replacement.1, + &template[replacement.0.end..] + ); + let document = Document::parse(&rendered).expect("replacement parses"); + let key_name = document + .descendants() + .find(|node| node.has_tag_name((XMLDSIG_NS, "KeyName"))) + .expect("recipient name remains present"); + assert_eq!(direct_simple_text(key_name, "KeyName").unwrap(), "valid"); + } + #[test] fn recipient_merge_keeps_parent_and_nested_insertions_disjoint() { // Outer key metadata, nested recipient identity, and ciphertext can all diff --git a/tools/xmlsec1/src/key_material.rs b/tools/xmlsec1/src/key_material.rs index 2244cfd0..d5092f99 100644 --- a/tools/xmlsec1/src/key_material.rs +++ b/tools/xmlsec1/src/key_material.rs @@ -23,7 +23,8 @@ use rsa::{ }, }; use x509_parser::prelude::FromDer as _; -use xml_sec::policy::{PolicyViolation, SigningPolicy, VerificationPolicy}; +use xml_sec::key_manager::{KeyInventory, KeyUsages}; +use xml_sec::policy::{PolicyViolation, ResourcePolicy, SigningPolicy, VerificationPolicy}; use xml_sec::xmldsig::{ DsaSigningKey, DsigError, EcdsaP256SigningKey, EcdsaP384SigningKey, EcdsaP521SigningKey, KeyInfo, ReferenceProcessingError, RsaSigningKey, SignatureAlgorithm, SigningKey, @@ -38,7 +39,7 @@ use zeroize::Zeroizing; // This is an absolute process-safety ceiling, not deployment policy. Parsed // key sizes remain governed by the operation policy after bounded ingestion. -const KEY_MATERIAL_BYTE_CEILING: usize = 8 * 1024 * 1024; +pub(crate) const KEY_MATERIAL_BYTE_CEILING: usize = 8 * 1024 * 1024; const MAX_AES_KEY_BYTES: usize = 32; #[derive(Debug, thiserror::Error)] @@ -52,6 +53,8 @@ pub enum KeyMaterialError { InvalidPem(PathBuf), #[error("unsupported private key in {}", .0.display())] UnsupportedPrivateKey(PathBuf), + #[error("protected key container could not be decoded")] + ProtectedContainer, #[error("unsupported public key in {}", .0.display())] UnsupportedPublicKey(PathBuf), #[error("invalid X.509 certificate in {}", .0.display())] @@ -115,23 +118,31 @@ pub enum CertificateEncoding { } pub fn read(path: impl AsRef) -> Result, KeyMaterialError> { + read_with_limit(path, KEY_MATERIAL_BYTE_CEILING) +} + +pub fn read_with_limit( + path: impl AsRef, + maximum_bytes: usize, +) -> Result, KeyMaterialError> { let path = path.as_ref(); - let mut bytes = Vec::with_capacity(KEY_MATERIAL_BYTE_CEILING.min(64 * 1024)); + let maximum = maximum_bytes.min(KEY_MATERIAL_BYTE_CEILING); + let mut bytes = Vec::with_capacity(maximum.min(64 * 1024)); File::open(path) .map_err(|source| KeyMaterialError::Read { path: path.to_owned(), source, })? - .take(KEY_MATERIAL_BYTE_CEILING.saturating_add(1) as u64) + .take(maximum.saturating_add(1) as u64) .read_to_end(&mut bytes) .map_err(|source| KeyMaterialError::Read { path: path.to_owned(), source, })?; - if bytes.len() > KEY_MATERIAL_BYTE_CEILING { + if bytes.len() > maximum { return Err(KeyMaterialError::KeyMaterialTooLarge { path: path.to_owned(), - maximum: KEY_MATERIAL_BYTE_CEILING, + maximum, }); } Ok(bytes) @@ -385,6 +396,10 @@ struct TraditionalDsaPrivateKey<'a> { x: UintRef<'a>, } +pub(crate) fn is_encrypted_pkcs8_container(bytes: &[u8], format: PrivateKeyFormat) -> bool { + pkcs8_container_kind(bytes, format) == Some(Pkcs8ContainerKind::Encrypted) +} + fn pkcs8_container_kind(bytes: &[u8], format: PrivateKeyFormat) -> Option { match format { PrivateKeyFormat::Pem | PrivateKeyFormat::Pkcs8Pem => { @@ -643,8 +658,16 @@ fn decode_traditional_rsa_pem( path: &Path, ) -> Result { let der = decode_openssl_traditional_pem(text, "RSA PRIVATE KEY", password, path)?; - RsaPrivateKey::from_pkcs1_der(&der) - .map_err(|_| KeyMaterialError::UnsupportedPrivateKey(path.to_owned())) + RsaPrivateKey::from_pkcs1_der(&der).map_err(|_| { + if pem::parse(text) + .ok() + .is_some_and(|block| block.headers().get("Proc-Type") == Some("4,ENCRYPTED")) + { + KeyMaterialError::ProtectedContainer + } else { + KeyMaterialError::UnsupportedPrivateKey(path.to_owned()) + } + }) } fn decode_openssl_traditional_pem( @@ -684,8 +707,7 @@ fn decode_openssl_traditional_pem( .ok_or_else(|| KeyMaterialError::UnsupportedPrivateKey(path.to_owned()))?; let iv = decode_hex(encoded_iv) .ok_or_else(|| KeyMaterialError::UnsupportedPrivateKey(path.to_owned()))?; - let password = - password.ok_or_else(|| KeyMaterialError::UnsupportedPrivateKey(path.to_owned()))?; + let password = password.ok_or(KeyMaterialError::ProtectedContainer)?; decrypt_openssl_legacy_pem(cipher, &iv, envelope.contents(), password, path) } @@ -735,7 +757,7 @@ fn decrypt_openssl_legacy_pem( let length = cbc::Decryptor::<$cipher>::new_from_slices(&key, iv) .map_err(|_| KeyMaterialError::UnsupportedPrivateKey(path.to_owned()))? .decrypt_padded::(&mut plaintext) - .map_err(|_| KeyMaterialError::UnsupportedPrivateKey(path.to_owned()))? + .map_err(|_| KeyMaterialError::ProtectedContainer)? .len(); plaintext.truncate(length); }}; @@ -894,17 +916,66 @@ pub fn load_rsa_private( /// Decode caller-owned RSA private-key bytes after the operation layer has /// charged their source length to its aggregate external-material budget. +#[cfg(test)] pub fn decode_rsa_private( path: &Path, bytes: &[u8], format: PrivateKeyFormat, ) -> Result { + decode_rsa_private_with_password(path, bytes, format, None, &ResourcePolicy::default()) +} + +/// Decode an RSA transport key without retrying plaintext formats after a +/// protected container fails password verification. +pub fn decode_rsa_private_with_password( + path: &Path, + bytes: &[u8], + format: PrivateKeyFormat, + password: Option<&[u8]>, + resources: &ResourcePolicy, +) -> Result { + if pkcs8_container_kind(bytes, format) == Some(Pkcs8ContainerKind::Encrypted) { + let password = password.ok_or(KeyMaterialError::ProtectedContainer)?; + let mut inventory = KeyInventory::default(); + let imported = match format { + PrivateKeyFormat::Pem | PrivateKeyFormat::Pkcs8Pem => inventory.add_private_pem( + "cli-rsa".into(), + bytes, + Some(password), + KeyUsages::DECRYPT, + resources, + ), + PrivateKeyFormat::Der | PrivateKeyFormat::Pkcs8Der => inventory.add_private_der( + "cli-rsa".into(), + bytes, + Some(password), + KeyUsages::DECRYPT, + resources, + ), + }; + imported.map_err(|error| match error { + xml_sec::key_manager::KeyStoreError::ProtectedContainer => { + KeyMaterialError::ProtectedContainer + } + xml_sec::key_manager::KeyStoreError::Policy(violation) => violation.into(), + _ => KeyMaterialError::UnsupportedPrivateKey(path.to_owned()), + })?; + return inventory + .private_keys() + .first() + .and_then(|entry| RsaPrivateKey::from_pkcs8_der(&entry.pkcs8_der).ok()) + .ok_or_else(|| KeyMaterialError::UnsupportedPrivateKey(path.to_owned())); + } match format { - PrivateKeyFormat::Pem => std::str::from_utf8(bytes).ok().and_then(|text| { - RsaPrivateKey::from_pkcs8_pem(text) - .or_else(|_| RsaPrivateKey::from_pkcs1_pem(text)) - .ok() - }), + PrivateKeyFormat::Pem => { + let text = std::str::from_utf8(bytes) + .map_err(|_| KeyMaterialError::UnsupportedPrivateKey(path.to_owned()))?; + if pkcs8_container_kind(bytes, format) == Some(Pkcs8ContainerKind::Plain) { + RsaPrivateKey::from_pkcs8_pem(text).ok() + } else { + return decode_traditional_rsa_pem(text, password, path); + } + } PrivateKeyFormat::Der => RsaPrivateKey::from_pkcs8_der(bytes) .or_else(|_| RsaPrivateKey::from_pkcs1_der(bytes)) .ok(), @@ -1021,6 +1092,68 @@ mod tests { use super::*; + #[test] + fn protected_rsa_container_failure_is_not_a_lax_candidate_miss() { + // A wrong or missing password must stop lax search before a later + // unprotected candidate can silently replace the requested key. + let rsa = RsaPrivateKey::from_pkcs8_pem(include_str!( + "../../../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("RSA fixture"); + let plain = rsa.to_pkcs8_der().expect("PKCS#8 fixture"); + let mut rng = ChaCha20Rng::seed_from_u64(0xA11C_E501); + let encrypted = PrivateKeyInfoRef::try_from(plain.as_bytes()) + .expect("PKCS#8 reference") + .encrypt_with_rng(&mut rng, b"correct") + .expect("encrypted fixture"); + for password in [None, Some(b"wrong".as_slice())] { + assert!(matches!( + decode_rsa_private_with_password( + Path::new("protected.der"), + encrypted.as_bytes(), + PrivateKeyFormat::Pkcs8Der, + password, + &ResourcePolicy::default(), + ), + Err(KeyMaterialError::ProtectedContainer) + )); + } + let invalid_policy = ResourcePolicy { + max_external_resource_bytes: usize::MAX, + ..ResourcePolicy::default() + }; + assert!(matches!( + decode_rsa_private_with_password( + Path::new("protected.der"), + encrypted.as_bytes(), + PrivateKeyFormat::Pkcs8Der, + Some(b"correct"), + &invalid_policy, + ), + Err(KeyMaterialError::Policy(_)) + )); + } + + #[test] + fn traditional_encrypted_rsa_pem_preserves_password_failure() { + // A protected traditional PEM must not look like a missing key to lax selection. + let pem = include_bytes!( + "../../../tests/fixtures/keys/rsa/rsa-2048-key-traditional-encrypted.pem" + ); + for password in [None, Some(b"wrong".as_slice())] { + assert!(matches!( + decode_rsa_private_with_password( + Path::new("protected.pem"), + pem, + PrivateKeyFormat::Pem, + password, + &ResourcePolicy::default(), + ), + Err(KeyMaterialError::ProtectedContainer) + )); + } + } + fn load_signing_key( path: impl AsRef, format: PrivateKeyFormat, diff --git a/tools/xmlsec1/tests/process_contract.rs b/tools/xmlsec1/tests/process_contract.rs index d3449f22..78a16d86 100644 --- a/tools/xmlsec1/tests/process_contract.rs +++ b/tools/xmlsec1/tests/process_contract.rs @@ -17,6 +17,7 @@ use rcgen::{ }; use rsa::{ RsaPrivateKey, RsaPublicKey, + pkcs1::DecodeRsaPrivateKey as _, pkcs8::{ DecodePrivateKey as _, DecodePublicKey as _, EncodePrivateKey as _, EncodePublicKey as _, }, @@ -46,6 +47,230 @@ fn project_root() -> &'static Path { Path::new(env!("CARGO_MANIFEST_DIR")) } +#[test] +fn donor_pkcs12_decrypts_and_wrong_password_fails_closed() { + // The PHAOS bundle and ciphertext are independent xmlsec1 oracle inputs. + let fixture = project_root().join("tests/fixtures/xmlenc/01-phaos-xmlenc-3"); + let encrypted = fixture.join("enc-element-aes128-kt-rsa_oaep_sha1.xml"); + let key = fixture.join("rsa-priv-key.p12"); + let run = |password: &str| { + Command::new(binary()) + .arg("decrypt") + .arg("--pkcs12:my-rsa-key") + .arg(&key) + .arg("--pwd") + .arg(password) + .arg(&encrypted) + .output() + .unwrap() + }; + let success = run("secret"); + assert!( + success.status.success(), + "{}", + String::from_utf8_lossy(&success.stderr) + ); + assert!(String::from_utf8_lossy(&success.stdout).contains("CreditCard")); + + let failure = run("wrong-password"); + assert!(!failure.status.success()); + assert!(!String::from_utf8_lossy(&failure.stderr).contains("wrong-password")); +} + +#[test] +fn donor_pkcs12_signs_without_exposing_password() { + // The PKCS#12 importer must feed the normal signing pipeline, not just RSA + // transport decryption, and a wrong password must not retry plaintext DER. + let fixture = project_root().join("tests/fixtures/xmlenc/01-phaos-xmlenc-3"); + let temp = tempfile::tempdir().unwrap(); + let template = temp.path().join("template.xml"); + let signed = temp.path().join("signed.xml"); + let public = temp.path().join("public.der"); + fs::write(&template, signature_template_without_key_info()).unwrap(); + let private = + RsaPrivateKey::from_pkcs1_der(&fs::read(fixture.join("rsa-priv-key.der")).unwrap()) + .unwrap(); + fs::write( + &public, + private + .to_public_key() + .to_public_key_der() + .unwrap() + .as_bytes(), + ) + .unwrap(); + let key = fixture.join("rsa-priv-key.p12"); + let sign = |password: &str| { + Command::new(binary()) + .arg("sign") + .arg("--pkcs12") + .arg(&key) + .arg("--pwd") + .arg(password) + .arg("--output") + .arg(&signed) + .arg(&template) + .output() + .unwrap() + }; + let success = sign("secret"); + assert!( + success.status.success(), + "{}", + String::from_utf8_lossy(&success.stderr) + ); + let verified = Command::new(binary()) + .arg("verify") + .arg("--pubkey-der") + .arg(&public) + .arg(&signed) + .output() + .unwrap(); + assert!( + verified.status.success(), + "{}", + String::from_utf8_lossy(&verified.stderr) + ); + + let failed = sign("wrong-password"); + assert!(!failed.status.success()); + assert!(!String::from_utf8_lossy(&failed.stderr).contains("wrong-password")); +} + +#[test] +fn lax_signing_stops_on_protected_pkcs12_failure() { + // A wrong container password is an invocation failure, not permission to + // use a later unprotected signing key from the lax candidate list. + let fixture = project_root().join("tests/fixtures/xmlenc/01-phaos-xmlenc-3"); + let temp = tempfile::tempdir().unwrap(); + let template = temp.path().join("template.xml"); + fs::write(&template, signature_template_without_key_info()).unwrap(); + let result = Command::new(binary()) + .args(["sign", "--lax-key-search", "--pkcs12:first"]) + .arg(fixture.join("rsa-priv-key.p12")) + .args(["--privkey-pem:second"]) + .arg(project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem")) + .args(["--pwd", "wrong-password"]) + .arg(&template) + .output() + .unwrap(); + assert!( + !result.status.success(), + "wrong PKCS#12 password was skipped" + ); + assert!(!String::from_utf8_lossy(&result.stderr).contains("wrong-password")); +} + +#[test] +fn lax_decryption_stops_on_protected_pkcs12_failure() { + // A protected-container authentication failure must not be bypassed by + // decrypting with a later plaintext private-key candidate. + let fixture = project_root().join("tests/fixtures/xmlenc/01-phaos-xmlenc-3"); + let result = Command::new(binary()) + .args(["decrypt", "--lax-key-search", "--pkcs12:my-rsa-key"]) + .arg(fixture.join("rsa-priv-key.p12")) + .args(["--privkey-der:second"]) + .arg(fixture.join("rsa-priv-key.der")) + .args(["--pwd", "wrong-password"]) + .arg(fixture.join("enc-element-aes128-kt-rsa_oaep_sha1.xml")) + .output() + .unwrap(); + assert!( + !result.status.success(), + "wrong PKCS#12 password was skipped" + ); + assert!(!String::from_utf8_lossy(&result.stderr).contains("wrong-password")); +} + +#[test] +fn lax_decryption_stops_on_traditional_encrypted_rsa_pem_failure() { + // A wrong password for the first protected RSA candidate cannot authorize + // fallback to a later unprotected key for the same recipient. + let temp = tempfile::tempdir().unwrap(); + let template = temp.path().join("template.xml"); + let plaintext = temp.path().join("plaintext.bin"); + let encrypted = temp.path().join("encrypted.xml"); + let keys = project_root().join("tests/fixtures/keys/rsa"); + fs::write(&template, r#""#).unwrap(); + fs::write(&plaintext, b"protected RSA recipient").unwrap(); + let encrypt = Command::new(binary()) + .args(["encrypt", "--pubkey-pem"]) + .arg(keys.join("rsa-2048-pubkey.pem")) + .arg("--binary-data") + .arg(&plaintext) + .arg("--output") + .arg(&encrypted) + .arg(&template) + .output() + .unwrap(); + assert!( + encrypt.status.success(), + "{}", + String::from_utf8_lossy(&encrypt.stderr) + ); + let decrypt = Command::new(binary()) + .args(["decrypt", "--lax-key-search", "--privkey-pem:first"]) + .arg(keys.join("rsa-2048-key-traditional-encrypted.pem")) + .arg("--privkey-pem:second") + .arg(keys.join("rsa-2048-key.pem")) + .args(["--pwd", "wrong-legacy-password-sentinel"]) + .arg(&encrypted) + .output() + .unwrap(); + assert!(!decrypt.status.success()); + assert!(!String::from_utf8_lossy(&decrypt.stderr).contains("wrong-legacy-password-sentinel")); +} + +#[test] +fn donor_xml_store_private_dsa_signs_and_public_dsa_verifies() { + // The upstream xmlsec extension carries DSA X only in the store. The + // signature document names the key but does not contain secret material. + let temp = tempfile::tempdir().unwrap(); + let template = temp.path().join("dsa-template.xml"); + let signed = temp.path().join("dsa-signed.xml"); + let source = signature_template_without_key_info() + .replace( + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "http://www.w3.org/2000/09/xmldsig#dsa-sha1", + ) + .replace( + "http://www.w3.org/2001/04/xmlenc#sha256", + "http://www.w3.org/2000/09/xmldsig#sha1", + ) + .replace( + "", + "test-dsa", + ); + fs::write(&template, source).unwrap(); + let store = project_root().join("tests/fixtures/keys/xmlsec/mixed-keys.xml"); + let sign = Command::new(binary()) + .arg("sign") + .arg("--keys-file") + .arg(&store) + .arg("--output") + .arg(&signed) + .arg(&template) + .output() + .unwrap(); + assert!( + sign.status.success(), + "{}", + String::from_utf8_lossy(&sign.stderr) + ); + let verify = Command::new(binary()) + .arg("verify") + .arg("--keys-file") + .arg(&store) + .arg(&signed) + .output() + .unwrap(); + assert!( + verify.status.success(), + "{}", + String::from_utf8_lossy(&verify.stderr) + ); +} + #[derive(der::Sequence)] struct TraditionalDsaPrivateKey<'a> { version: u8, @@ -444,6 +669,154 @@ fn compatibility_cli_signs_hmac_templates_with_named_raw_keys() { assert!(String::from_utf8_lossy(&rejected_verify.stderr).contains("configured minimum")); } +#[test] +fn key_store_supplies_named_hmac_key_for_sign_and_verify() { + // A libxmlsec1 key store is an input key source, not merely output of the + // `keys` command. A missing or malformed store must not fall back to an + // unrelated key. + let temp = tempfile::tempdir().unwrap(); + let template = project_root().join( + "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1.tmpl", + ); + let key_store = temp.path().join("keys.xml"); + let signed = temp.path().join("signed.xml"); + let secret = fs::read(project_root().join("tests/fixtures/keys/hmackey.bin")).unwrap(); + let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, secret); + fs::write( + &key_store, + format!( + "TeskKeyName-Hmac{encoded}" + ), + ) + .unwrap(); + + let sign = Command::new(binary()) + .args(["sign", "--keys-file"]) + .arg(&key_store) + .arg("--output") + .arg(&signed) + .arg(&template) + .output() + .unwrap(); + assert!( + sign.status.success(), + "{}", + String::from_utf8_lossy(&sign.stderr) + ); + + let verify = Command::new(binary()) + .args(["verify", "--keys-file"]) + .arg(&key_store) + .arg(&signed) + .output() + .unwrap(); + assert!( + verify.status.success(), + "{}", + String::from_utf8_lossy(&verify.stderr) + ); + + let duplicate = Command::new(binary()) + .args(["verify", "--keys-file"]) + .arg(&key_store) + .arg("--keys-file") + .arg(&key_store) + .arg(&signed) + .output() + .unwrap(); + assert!(!duplicate.status.success()); + assert!(String::from_utf8_lossy(&duplicate.stderr).contains("duplicate key name")); + + let malformed = temp.path().join("malformed.xml"); + fs::write(&malformed, "").unwrap(); + let rejected = Command::new(binary()) + .args(["verify", "--keys-file"]) + .arg(&malformed) + .arg(&signed) + .output() + .unwrap(); + assert!(!rejected.status.success()); +} + +#[test] +fn key_store_accepts_mixed_upstream_key_types() { + // The upstream keys.xml intentionally mixes symmetric, RSA, DSA, and + // additional key families. An unsupported entry cannot invalidate a + // separately usable HMAC entry in the same store. + let template = project_root().join( + "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1.tmpl", + ); + let store = project_root().join("tests/fixtures/keys/xmlsec/mixed-keys.xml"); + let output = Command::new(binary()) + .args(["sign", "--lax-key-search", "--keys-file"]) + .arg(&store) + .arg(&template) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn lax_key_store_verification_skips_incompatible_family() { + // The first named store entry is DSA; the RSA signature must be checked + // against the later compatible entry instead of failing at the first key. + let temp = tempfile::tempdir().unwrap(); + let template = project_root() + .join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl"); + let signed = temp.path().join("signed.xml"); + let sign = Command::new(binary()) + .args(["sign", "--privkey-pem"]) + .arg(project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem")) + .arg("--output") + .arg(&signed) + .arg(&template) + .output() + .unwrap(); + assert!( + sign.status.success(), + "{}", + String::from_utf8_lossy(&sign.stderr) + ); + + let donor = + fs::read_to_string(project_root().join("tests/fixtures/keys/xmlsec/mixed-keys.xml")) + .unwrap(); + let dsa_name = donor.find("test-dsa").unwrap(); + let dsa_start = donor[..dsa_name].rfind("").unwrap() + dsa_name + "".len(); + let public_pem = + fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem")) + .unwrap(); + let public = RsaPublicKey::from_public_key_pem(&public_pem).unwrap(); + let base64 = base64::engine::general_purpose::STANDARD; + let store = temp.path().join("keys.xml"); + fs::write( + &store, + format!( + "{}rsa{}{}", + &donor[dsa_start..dsa_end], + base64.encode(public.n().to_be_bytes_trimmed_vartime()), + base64.encode(public.e().to_be_bytes_trimmed_vartime()), + ), + ) + .unwrap(); + let verify = Command::new(binary()) + .args(["verify", "--lax-key-search", "--keys-file"]) + .arg(&store) + .arg(&signed) + .output() + .unwrap(); + assert!( + verify.status.success(), + "{}", + String::from_utf8_lossy(&verify.stderr) + ); +} + #[test] #[expect( deprecated, @@ -577,6 +950,28 @@ fn compatibility_cli_decodes_dsa_and_p521_pkcs8_signing_keys() { .as_bytes(), ) .unwrap(); + let compatible_private = temp.path().join("dsa-2048-private.der"); + fs::write( + &compatible_private, + dsa_key.to_pkcs8_der().unwrap().as_bytes(), + ) + .unwrap(); + let lax_signed = temp.path().join("dsa-lax-signed.xml"); + let lax_sign = Command::new(binary()) + .args(["sign", "--lax-key-search", "--pkcs8-der:wrong"]) + .arg(&legacy_private) + .arg("--pkcs8-der:TestKeyName-dsa-2048") + .arg(&compatible_private) + .arg("--output") + .arg(&lax_signed) + .arg(&dsa_template) + .output() + .unwrap(); + assert!( + lax_sign.status.success(), + "{}", + String::from_utf8_lossy(&lax_sign.stderr) + ); let donor_legacy_template = project_root() .join("tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-dsa.tmpl"); let legacy_template = temp.path().join("dsa-1024-template.xml"); @@ -3072,6 +3467,628 @@ fn encrypts_decrypts_and_rejects_wrong_symmetric_key() { assert!(!rejected.status.success()); } +#[test] +fn key_store_supplies_aes_key_for_encryption_and_decryption() { + // The same named key store must serve both sides of a binary encryption + // round trip; a second store with different material must not decrypt it. + let temp = tempfile::tempdir().unwrap(); + let template = temp.path().join("template.xml"); + let plaintext = temp.path().join("plaintext.bin"); + let store = temp.path().join("keys.xml"); + let wrong_store = temp.path().join("wrong.xml"); + let encrypted = temp.path().join("encrypted.xml"); + let decrypted = temp.path().join("decrypted.bin"); + fs::write( + &template, + r#"content"#, + ) + .unwrap(); + fs::write(&plaintext, b"key-store round trip\0\xff").unwrap(); + for (path, key) in [ + (&store, b"0123456789abcdef"), + (&wrong_store, b"fedcba9876543210"), + ] { + let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key); + fs::write(path, format!("content{encoded}")).unwrap(); + } + let encrypt = Command::new(binary()) + .args(["encrypt", "--keys-file"]) + .arg(&store) + .arg("--binary-data") + .arg(&plaintext) + .arg("--output") + .arg(&encrypted) + .arg(&template) + .output() + .unwrap(); + assert!( + encrypt.status.success(), + "{}", + String::from_utf8_lossy(&encrypt.stderr) + ); + let decrypt = Command::new(binary()) + .args(["decrypt", "--keys-file"]) + .arg(&store) + .arg("--output") + .arg(&decrypted) + .arg(&encrypted) + .output() + .unwrap(); + assert!( + decrypt.status.success(), + "{}", + String::from_utf8_lossy(&decrypt.stderr) + ); + assert_eq!(fs::read(&decrypted).unwrap(), fs::read(&plaintext).unwrap()); + // An embedded recipient does not invalidate a separate direct content key. + // Both explicit and stored direct keys must take the same selection path. + let with_recipient = temp.path().join("encrypted-with-recipient.xml"); + let direct_key = temp.path().join("content.key"); + fs::write(&direct_key, b"0123456789abcdef").unwrap(); + let encrypted_xml = fs::read_to_string(&encrypted).unwrap(); + assert!(encrypted_xml.contains("")); + let encrypted_xml = encrypted_xml.replacen( + "", + "recipientAA==", + 1, + ); + fs::write(&with_recipient, encrypted_xml).unwrap(); + let explicit = Command::new(binary()) + .args(["decrypt", "--aes-key:content"]) + .arg(&direct_key) + .arg(&with_recipient) + .output() + .unwrap(); + assert!( + explicit.status.success(), + "{}", + String::from_utf8_lossy(&explicit.stderr) + ); + assert_eq!(explicit.stdout, fs::read(&plaintext).unwrap()); + let stored = Command::new(binary()) + .args(["decrypt", "--keys-file"]) + .arg(&store) + .arg(&with_recipient) + .output() + .unwrap(); + assert!( + stored.status.success(), + "{}", + String::from_utf8_lossy(&stored.stderr) + ); + assert_eq!(stored.stdout, fs::read(&plaintext).unwrap()); + let wrong = Command::new(binary()) + .args(["decrypt", "--keys-file"]) + .arg(&wrong_store) + .arg(&encrypted) + .output() + .unwrap(); + assert!(!wrong.status.success()); + + let mixed_store = temp.path().join("mixed.xml"); + let wrong_encoded = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + b"fedcba9876543210", + ); + let right_encoded = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + b"0123456789abcdef", + ); + fs::write( + &mixed_store, + format!("wrong{wrong_encoded}content{right_encoded}"), + ) + .unwrap(); + let lax = Command::new(binary()) + .args(["decrypt", "--lax-key-search", "--keys-file"]) + .arg(&mixed_store) + .arg(&encrypted) + .output() + .unwrap(); + assert!( + lax.status.success(), + "{}", + String::from_utf8_lossy(&lax.stderr) + ); + assert_eq!(lax.stdout, fs::read(&plaintext).unwrap()); +} + +#[test] +fn key_store_rsa_recipient_round_trips_with_explicit_private_key() { + // A named RSAKeyValue in the imported store must serve an EncryptedKey + // recipient without an additional public-key file option. + let temp = tempfile::tempdir().unwrap(); + let template = temp.path().join("template.xml"); + let store = temp.path().join("keys.xml"); + let plaintext = temp.path().join("plaintext.bin"); + let encrypted = temp.path().join("encrypted.xml"); + let public_pem = + fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem")) + .unwrap(); + let public = RsaPublicKey::from_public_key_pem(&public_pem).unwrap(); + let base64 = base64::engine::general_purpose::STANDARD; + fs::write( + &store, + format!( + "recipient{}{}", + base64.encode(public.n().to_be_bytes_trimmed_vartime()), + base64.encode(public.e().to_be_bytes_trimmed_vartime()), + ), + ) + .unwrap(); + fs::write( + &template, + r#"recipient"#, + ) + .unwrap(); + fs::write(&plaintext, b"named RSA recipient payload").unwrap(); + let encrypt = Command::new(binary()) + .args(["encrypt", "--keys-file"]) + .arg(&store) + .arg("--binary-data") + .arg(&plaintext) + .arg("--output") + .arg(&encrypted) + .arg(&template) + .output() + .unwrap(); + assert!( + encrypt.status.success(), + "{}", + String::from_utf8_lossy(&encrypt.stderr) + ); + let decrypt = Command::new(binary()) + .args(["decrypt", "--privkey-pem"]) + .arg(project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem")) + .arg(&encrypted) + .output() + .unwrap(); + assert!( + decrypt.status.success(), + "{}", + String::from_utf8_lossy(&decrypt.stderr) + ); + assert_eq!(decrypt.stdout, fs::read(&plaintext).unwrap()); + + // Lax lookup must still prefer the recipient's exact name over an earlier + // usable-but-wrong RSA key in the same store. + let wrong_public_pem = + fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem")) + .unwrap(); + let wrong_public = RsaPublicKey::from_public_key_pem(&wrong_public_pem).unwrap(); + fs::write( + &store, + format!( + "wrong{}{}recipient{}{}", + base64.encode(wrong_public.n().to_be_bytes_trimmed_vartime()), + base64.encode(wrong_public.e().to_be_bytes_trimmed_vartime()), + base64.encode(public.n().to_be_bytes_trimmed_vartime()), + base64.encode(public.e().to_be_bytes_trimmed_vartime()), + ), + ) + .unwrap(); + let lax_encrypted = temp.path().join("lax-encrypted.xml"); + let lax_encrypt = Command::new(binary()) + .args(["encrypt", "--lax-key-search", "--keys-file"]) + .arg(&store) + .arg("--binary-data") + .arg(&plaintext) + .arg("--output") + .arg(&lax_encrypted) + .arg(&template) + .output() + .unwrap(); + assert!( + lax_encrypt.status.success(), + "{}", + String::from_utf8_lossy(&lax_encrypt.stderr) + ); + let lax_decrypt = Command::new(binary()) + .args(["decrypt", "--privkey-pem"]) + .arg(project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem")) + .arg(&lax_encrypted) + .output() + .unwrap(); + assert!( + lax_decrypt.status.success(), + "{}", + String::from_utf8_lossy(&lax_decrypt.stderr) + ); + assert_eq!(lax_decrypt.stdout, fs::read(&plaintext).unwrap()); + + let unsupported_store_decrypt = Command::new(binary()) + .args(["decrypt", "--keys-file"]) + .arg(&store) + .arg(&encrypted) + .output() + .unwrap(); + assert!(!unsupported_store_decrypt.status.success()); + assert!( + String::from_utf8_lossy(&unsupported_store_decrypt.stderr) + .contains("--keys-file does not supply RSA recipient private keys") + ); + + let wrong_public_pem = + fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem")) + .unwrap(); + let wrong_public = RsaPublicKey::from_public_key_pem(&wrong_public_pem).unwrap(); + let conflicting = format!( + "recipient{}{}", + base64.encode(wrong_public.n().to_be_bytes_trimmed_vartime()), + base64.encode(wrong_public.e().to_be_bytes_trimmed_vartime()), + ); + fs::write(&template, conflicting).unwrap(); + let rejected = Command::new(binary()) + .args(["encrypt", "--keys-file"]) + .arg(&store) + .arg("--binary-data") + .arg(&plaintext) + .arg(&template) + .output() + .unwrap(); + assert!(!rejected.status.success()); +} + +#[test] +fn lax_store_encryption_replaces_stale_content_key_name() { + // Lax fallback must publish the selected content-key identity so a strict + // decryptor can select that same key from the resulting document. + let temp = tempfile::tempdir().unwrap(); + let template = temp.path().join("template.xml"); + let store = temp.path().join("keys.xml"); + let plaintext = temp.path().join("plaintext.bin"); + let encrypted = temp.path().join("encrypted.xml"); + fs::write( + &template, + r#"selected-old"#, + ) + .unwrap(); + let encoded = base64::engine::general_purpose::STANDARD.encode(b"0123456789abcdef"); + fs::write( + &store, + format!("selected{encoded}"), + ) + .unwrap(); + fs::write(&plaintext, b"lax content key fallback").unwrap(); + let encrypt = Command::new(binary()) + .args(["encrypt", "--lax-key-search", "--keys-file"]) + .arg(&store) + .arg("--binary-data") + .arg(&plaintext) + .arg("--output") + .arg(&encrypted) + .arg(&template) + .output() + .unwrap(); + assert!( + encrypt.status.success(), + "{}", + String::from_utf8_lossy(&encrypt.stderr) + ); + let xml = fs::read_to_string(&encrypted).unwrap(); + let document = roxmltree::Document::parse(&xml).unwrap(); + let content_key_name = document + .root_element() + .children() + .find(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyInfo"))) + .and_then(|key_info| { + key_info + .children() + .find(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyName"))) + }) + .and_then(|node| node.text()); + assert_eq!(content_key_name, Some("selected")); + let decrypt = Command::new(binary()) + .args(["decrypt", "--keys-file"]) + .arg(&store) + .arg(&encrypted) + .output() + .unwrap(); + assert!( + decrypt.status.success(), + "{}", + String::from_utf8_lossy(&decrypt.stderr) + ); + assert_eq!(decrypt.stdout, b"lax content key fallback"); +} + +#[test] +fn lax_store_rsa_recipients_consume_distinct_candidates() { + // Lax selection consumes each entry once, including exact and singleton + // matches; every recipient must decrypt and exhaustion must emit no output. + let temp = tempfile::tempdir().unwrap(); + let store = temp.path().join("keys.xml"); + let template = temp.path().join("template.xml"); + let plaintext = temp.path().join("plaintext.bin"); + let encrypted = temp.path().join("encrypted.xml"); + let base64 = base64::engine::general_purpose::STANDARD; + let mut entries = Vec::new(); + for (name, bits) in [("a", 2048), ("b", 4096)] { + let pem = fs::read_to_string( + project_root().join(format!("tests/fixtures/keys/rsa/rsa-{bits}-pubkey.pem")), + ) + .unwrap(); + let public = RsaPublicKey::from_public_key_pem(&pem).unwrap(); + entries.push(format!( + "{name}{}{}", + base64.encode(public.n().to_be_bytes_trimmed_vartime()), + base64.encode(public.e().to_be_bytes_trimmed_vartime()), + )); + } + fs::write( + &store, + format!( + "{}", + entries.join("") + ), + ) + .unwrap(); + fs::write(&plaintext, b"distinct store recipients").unwrap(); + let write_template = |names: &[Option<&str>]| { + let recipients = names.iter().map(|name| { + let key_info = name.map_or_else(String::new, |name| format!("{name}")); + format!("{key_info}") + }).collect::(); + fs::write(&template, format!("{recipients}")).unwrap(); + }; + let encrypt = |output: &Path| { + Command::new(binary()) + .args(["encrypt", "--lax-key-search", "--keys-file"]) + .arg(&store) + .arg("--binary-data") + .arg(&plaintext) + .arg("--output") + .arg(output) + .arg(&template) + .output() + .unwrap() + }; + for names in [ + [None, None], + [Some("unknown-a"), Some("unknown-b")], + [Some("a"), None], + [None, Some("a")], + ] { + write_template(&names); + let result = encrypt(&encrypted); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + // A fallback cannot steal the key requested by a later named slot. + if names == [None, Some("a")] { + let xml = fs::read_to_string(&encrypted).unwrap(); + let document = roxmltree::Document::parse(&xml).unwrap(); + let assigned = document + .descendants() + .filter(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyName"))) + .map(|node| node.text().unwrap()) + .collect::>(); + assert_eq!(assigned, ["b", "a"]); + } + for bits in [2048, 4096] { + let result = Command::new(binary()) + .args(["decrypt", "--lax-key-search", "--privkey-pem"]) + .arg(project_root().join(format!("tests/fixtures/keys/rsa/rsa-{bits}-key.pem"))) + .arg(&encrypted) + .output() + .unwrap(); + assert!( + result.status.success(), + "recipient {bits}, names {names:?}: {}", + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!(result.stdout, b"distinct store recipients"); + } + } + // A stale later name must not reserve a key contradicted by its RSA + // metadata: the unnamed first slot needs a, and the later slot needs b. + let first_info = entries[0].replace("a", ""); + let second_info = entries[1].replace("b", "a"); + let recipients = [first_info, second_info].into_iter().map(|info| format!( + "{info}" + )).collect::(); + fs::write(&template, format!("{recipients}")).unwrap(); + let result = encrypt(&encrypted); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let xml = fs::read_to_string(&encrypted).unwrap(); + let document = roxmltree::Document::parse(&xml).unwrap(); + assert_eq!( + document + .descendants() + .filter(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyName"))) + .map(|node| node.text().unwrap()) + .collect::>(), + ["a", "b"] + ); + for bits in [2048, 4096] { + let decrypted = Command::new(binary()) + .args(["decrypt", "--lax-key-search", "--privkey-pem"]) + .arg(project_root().join(format!("tests/fixtures/keys/rsa/rsa-{bits}-key.pem"))) + .arg(&encrypted) + .output() + .unwrap(); + assert!( + decrypted.status.success(), + "{}", + String::from_utf8_lossy(&decrypted.stderr) + ); + assert_eq!(decrypted.stdout, b"distinct store recipients"); + } + for (names, single_key) in [(vec![None, None, None], false), (vec![None, None], true)] { + if single_key { + fs::write( + &store, + format!( + "{}", + entries[0] + ), + ) + .unwrap(); + } + write_template(&names); + let output = temp.path().join(if single_key { + "singleton.xml" + } else { + "exhausted.xml" + }); + let result = encrypt(&output); + assert!(!result.status.success()); + assert!( + String::from_utf8_lossy(&result.stderr) + .contains("no compatible RSA key in --keys-file") + ); + assert!(!output.exists()); + assert!(result.stdout.is_empty()); + } +} + +#[test] +fn lax_rsa_recipients_charge_only_attempted_store_keys() { + // Two exact recipients must not each consume the 33 unused lax fallbacks. + let temp = tempfile::tempdir().unwrap(); + let template = temp.path().join("template.xml"); + let store = temp.path().join("keys.xml"); + let plaintext = temp.path().join("plaintext.bin"); + let encrypted = temp.path().join("encrypted.xml"); + let public_pem = + fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem")) + .unwrap(); + let public = RsaPublicKey::from_public_key_pem(&public_pem).unwrap(); + let base64 = base64::engine::general_purpose::STANDARD; + let modulus = base64.encode(public.n().to_be_bytes_trimmed_vartime()); + let exponent = base64.encode(public.e().to_be_bytes_trimmed_vartime()); + let mut key_store = String::from(""); + for index in 0..33 { + key_store.push_str(&format!( + "recipient-{index}{modulus}{exponent}" + )); + } + key_store.push_str(""); + fs::write(&store, key_store).unwrap(); + let mut recipient_nodes = String::new(); + for index in 0..2 { + recipient_nodes.push_str(&format!( + "recipient-{index}" + )); + } + fs::write( + &template, + format!( + "{recipient_nodes}" + ), + ) + .unwrap(); + fs::write(&plaintext, b"two named recipients").unwrap(); + let encrypt = Command::new(binary()) + .args(["encrypt", "--lax-key-search", "--keys-file"]) + .arg(&store) + .arg("--binary-data") + .arg(&plaintext) + .arg("--output") + .arg(&encrypted) + .arg(&template) + .output() + .unwrap(); + assert!( + encrypt.status.success(), + "{}", + String::from_utf8_lossy(&encrypt.stderr) + ); + let decrypt = Command::new(binary()) + .args(["decrypt", "--privkey-pem"]) + .arg(project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem")) + .arg(&encrypted) + .output() + .unwrap(); + assert!(decrypt.status.success()); + assert_eq!(decrypt.stdout, b"two named recipients"); +} + +#[test] +fn lax_cached_recipients_charge_decoding_once() { + // Reservation validates immutable metadata and retains the decoded key. + // Reusing it must not consume another candidate, including at the full cap. + let temp = tempfile::tempdir().unwrap(); + let store = temp.path().join("keys.xml"); + let template = temp.path().join("template.xml"); + let plaintext = temp.path().join("plaintext.bin"); + let encrypted = temp.path().join("encrypted.xml"); + let public_pem = + fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem")) + .unwrap(); + let public = RsaPublicKey::from_public_key_pem(&public_pem).unwrap(); + let base64 = base64::engine::general_purpose::STANDARD; + let modulus = base64.encode(public.n().to_be_bytes_trimmed_vartime()); + let exponent = base64.encode(public.e().to_be_bytes_trimmed_vartime()); + let key_value = format!( + "{modulus}{exponent}" + ); + fs::write(&plaintext, b"cached recipient boundary").unwrap(); + for count in [33, 64] { + let mut key_store = String::from( + "", + ); + let mut recipients = String::new(); + for index in 0..count { + key_store.push_str(&format!( + "recipient-{index}{key_value}" + )); + recipients.push_str(&format!( + "recipient-{index}{key_value}" + )); + } + key_store.push_str(""); + fs::write(&store, key_store).unwrap(); + fs::write(&template, format!( + "{recipients}" + )).unwrap(); + let encrypt = Command::new(binary()) + .args(["encrypt", "--lax-key-search", "--keys-file"]) + .arg(&store) + .arg("--binary-data") + .arg(&plaintext) + .arg("--output") + .arg(&encrypted) + .arg(&template) + .output() + .unwrap(); + assert!( + encrypt.status.success(), + "{count} recipients: {}", + String::from_utf8_lossy(&encrypt.stderr) + ); + let xml = fs::read_to_string(&encrypted).unwrap(); + let document = roxmltree::Document::parse(&xml).unwrap(); + let actual_names: Vec<_> = document + .descendants() + .filter(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyName"))) + .map(|node| node.text().unwrap()) + .collect(); + let expected_names: Vec<_> = (0..count) + .map(|index| format!("recipient-{index}")) + .collect(); + assert_eq!(actual_names, expected_names); + let decrypt = Command::new(binary()) + .args(["decrypt", "--privkey-pem:recipient-0"]) + .arg(project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem")) + .arg(&encrypted) + .output() + .unwrap(); + assert!( + decrypt.status.success(), + "{}", + String::from_utf8_lossy(&decrypt.stderr) + ); + assert_eq!(decrypt.stdout, b"cached recipient boundary"); + } +} + #[test] fn encryption_writes_requested_diagnostics_and_rejects_duplicate_methods() { // Encryption diagnostics are a separate stdout contract, and malformed