From 184cbc2a7639e48e6c9faeebf57116e53d828d66 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 1 Oct 2026 18:22:42 +0300 Subject: [PATCH 1/9] feat(keys): add caller-owned key inventory Add bounded caller-owned key inventories, protected key and certificate imports, and CLI integration for signing, verification, encryption, and decryption. Enforce typed resource policy, exact-name selection, usage restrictions, and aggregate import and recipient budgets. Include negative and interoperability coverage and public documentation. Closes #167 --- .github/workflows/ci.yml | 2 +- Cargo.toml | 8 + README.md | 3 + crates/xml-sec-xslt/src/model.rs | 1 - docs/cli.md | 10 + docs/key-management.md | 142 + src/document.rs | 23 +- src/hard_limits.rs | 11 + src/key_manager.rs | 5046 +++++++++++++++++ src/key_manager/pkcs12_import.rs | 1254 ++++ src/lib.rs | 2 + src/policy.rs | 34 + src/provider.rs | 3 +- src/sxd_xpath/function.rs | 2 +- src/xmldsig/keys.rs | 484 +- src/xmldsig/sign.rs | 51 +- src/xmldsig/signature.rs | 48 +- src/xmldsig/x509.rs | 38 +- src/xmlenc/decrypt.rs | 5 +- src/xmlenc/mod.rs | 1 + tests/donor_interop_suite.rs | 8 +- tests/fixtures/keys/pkcs12/ec-key.p12.b64 | 1 + .../keys/pkcs12/rsa-duplicate-leaf.p12.b64 | 1 + .../keys/pkcs12/rsa-key-unrelated-ca.p12.b64 | 1 + tests/fixtures/keys/xmlsec/mixed-keys.xml | 52 + tests/fixtures_smoke.rs | 2 +- tests/key_manager_feature_contract.rs | 11 + tests/xmlenc_encrypt_xmlsec1.rs | 40 + tools/xmlsec1/src/args.rs | 1 + tools/xmlsec1/src/commands.rs | 1263 ++++- tools/xmlsec1/src/key_material.rs | 165 +- tools/xmlsec1/tests/process_contract.rs | 1017 ++++ 32 files changed, 9603 insertions(+), 127 deletions(-) create mode 100644 docs/key-management.md create mode 100644 src/key_manager.rs create mode 100644 src/key_manager/pkcs12_import.rs create mode 100644 tests/fixtures/keys/pkcs12/ec-key.p12.b64 create mode 100644 tests/fixtures/keys/pkcs12/rsa-duplicate-leaf.p12.b64 create mode 100644 tests/fixtures/keys/pkcs12/rsa-key-unrelated-ca.p12.b64 create mode 100644 tests/fixtures/keys/xmlsec/mixed-keys.xml create mode 100644 tests/key_manager_feature_contract.rs 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..86f8cd2b 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/crates/xml-sec-xslt/src/model.rs b/crates/xml-sec-xslt/src/model.rs index eeaaba51..30a79a48 100644 --- a/crates/xml-sec-xslt/src/model.rs +++ b/crates/xml-sec-xslt/src/model.rs @@ -1399,7 +1399,6 @@ impl Document { Ok(()) } - #[must_use] pub fn nodes(&self) -> impl ExactSizeIterator { self.nodes .iter() 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..6b261171 --- /dev/null +++ b/docs/key-management.md @@ -0,0 +1,142 @@ +# 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. +Unsupported digest, PRF, KDF, and cipher algorithms return a selection error, +not a protected-container error; RC2 containers are not supported. +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..2a6781af --- /dev/null +++ b/src/key_manager/pkcs12_import.rs @@ -0,0 +1,1254 @@ +//! 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); + } + } + Err(KeyStoreError::Selection(if hmac { + "unsupported PKCS#12 PRF algorithm" + } else { + "unsupported PKCS#12 digest algorithm" + })) + } +} + +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 Err(KeyStoreError::Selection( + "unsupported PKCS#12 PBES2 KDF algorithm", + )); + } + 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 Err(KeyStoreError::Selection( + "unsupported PKCS#12 PBES2 encryption scheme", + )); + }; + 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 Err(KeyStoreError::Selection( + "unsupported PKCS#12 encryption algorithm", + )); + }; + 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 unsupported_algorithms_are_distinct_from_bad_passwords() { + // Unsupported capabilities must fail during preflight, rather than + // suggesting that a password was tried and could not decode the key. + let unsupported = Oid::new_unwrap("1.2.840.113549.1.12.1.6"); + for hmac in [false, true] { + assert!(matches!( + Hash::from_oid(unsupported, hmac), + Err(KeyStoreError::Selection(_)) + )); + } + let derivation = sequence(&[encoded(4, b"salt"), integer(2)]); + for algorithm in [ + sequence(&[oid(unsupported), derivation.clone()]), + sequence(&[ + oid(PBES2), + sequence(&[ + sequence(&[oid(PBKDF2), derivation.clone()]), + sequence(&[oid(unsupported), encoded(4, &[0; 16])]), + ]), + ]), + sequence(&[ + oid(PBES2), + sequence(&[ + sequence(&[oid(unsupported), derivation]), + sequence(&[ + oid(pkcs8::pkcs5::pbes2::AES_128_CBC_OID), + encoded(4, &[0; 16]), + ]), + ]), + ]), + ] { + let bytes = pfx(&[data(&sequence(&[bag( + pkcs12::PKCS_12_PKCS8_KEY_BAG_OID, + &sequence(&[algorithm, encoded(4, &[0; 16])]), + )]))]); + assert!(matches!( + prepare(&bytes, &limits(64)), + Err(KeyStoreError::Selection(_)) + )); + } + } + + #[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/sxd_xpath/function.rs b/src/sxd_xpath/function.rs index b22e5140..ce9a8dd4 100644 --- a/src/sxd_xpath/function.rs +++ b/src/sxd_xpath/function.rs @@ -803,7 +803,7 @@ pub fn register_core_functions(context: &mut context::Context<'_>) { #[cfg(test)] mod test { use std::borrow::ToOwned; - use std::{f64, fmt}; + use std::fmt; #[cfg(feature = "embedded")] use super::sxd_document_no_unsafe; 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 From 9521bbd363ec3c41a9d06f1ad19b10b3af03737f Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 1 Oct 2026 19:49:49 +0300 Subject: [PATCH 2/9] fix(keys): enforce aggregate import contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Honor private PEM protection labels and BER high-tag identifiers. Share candidate inspection budgets across container records and stored verification sources, and preserve typed policy denials instead of retrying them as key misses. Avoid unnecessary KeyInfo and CRL copies. Add boundary, malformed-input and CLI regression coverage; update the key-management contract. проверено на локальных учетных --- docs/key-management.md | 14 +- src/key_manager.rs | 330 +++++++++++++++++++----- src/key_manager/pkcs12_import.rs | 102 +++++++- src/xmldsig/keys.rs | 87 ++++++- src/xmldsig/mod.rs | 4 +- tools/xmlsec1/src/commands.rs | 98 +++++-- tools/xmlsec1/tests/process_contract.rs | 39 +++ 7 files changed, 570 insertions(+), 104 deletions(-) diff --git a/docs/key-management.md b/docs/key-management.md index 6b261171..647f6e6b 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -50,7 +50,7 @@ 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, +Import and 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 @@ -77,7 +77,11 @@ 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 +triggers an unprotected fallback. PEM private-key labels must match their +payload: `ENCRYPTED PRIVATE KEY` cannot contain plaintext PKCS#8, and +`PRIVATE KEY` cannot contain an encrypted container. RFC 7468 section 2 +permits reinterpretation, but this API deliberately forbids it to preserve +the protected-key contract. 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 @@ -93,7 +97,11 @@ PBES2/PBKDF2 with AES-CBC and legacy SHA-1/3DES containers, plus SHA-1/SHA-2 MAC Unsupported digest, PRF, KDF, and cipher algorithms return a selection error, not a protected-container error; RC2 containers are not supported. 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. +share the same candidate and KDF budgets with ContentInfo records; a count +denial is not a password error. Lax CLI verification also shares one inspection +budget across all stored candidates and stops on a policy denial even after +another candidate resolved. Custom bag attributes accept BER high-tag-number +identifiers (X.690 section 8.1.2.4) without retaining their values. 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 diff --git a/src/key_manager.rs b/src/key_manager.rs index 6736b884..05612474 100644 --- a/src/key_manager.rs +++ b/src/key_manager.rs @@ -726,15 +726,19 @@ impl KeyInventory { .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")); + return Err(import_resource_limit( + crate::policy::resource_name::KEY_CANDIDATES, + resources.max_key_candidates, + )); } 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", + return Err(import_resource_limit( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_total_bytes, )); } let mut names = HashSet::new(); @@ -941,8 +945,13 @@ impl KeyInventory { resources: &ResourcePolicy, ) -> Result<(), KeyStoreError> { self.check_new_name(&name, resources)?; + if bytes.len() > resources.max_external_resource_bytes { + return Err(import_resource_limit( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_bytes, + )); + } 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")); @@ -1005,8 +1014,9 @@ impl KeyInventory { 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", + return Err(import_resource_limit( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_bytes, )); } self.check_material_capacity(named_material_length(&name, der.len(), 2)?, resources)?; @@ -1136,8 +1146,9 @@ impl KeyInventory { ) -> 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", + return Err(import_resource_limit( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_bytes, )); } self.check_material_capacity(named_material_length(&name, bytes.len(), 1)?, resources)?; @@ -1166,8 +1177,9 @@ impl KeyInventory { Zeroizing::new(normalized.as_bytes().to_vec()) }; if der.len() > resources.max_external_resource_bytes { - return Err(KeyStoreError::Selection( - "private key exceeds resource limit", + return Err(import_resource_limit( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_bytes, )); } private_key_spki(&der)?; @@ -1206,8 +1218,9 @@ impl KeyInventory { { self.check_new_name(&name, resources)?; if bytes.len() > resources.max_external_resource_bytes { - return Err(KeyStoreError::Selection( - "private key exceeds resource limit", + return Err(import_resource_limit( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_bytes, )); } self.check_material_capacity(named_material_length(&name, bytes.len(), 1)?, resources)?; @@ -1240,19 +1253,42 @@ impl KeyInventory { 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(()) + enum Payload { + Plain, + Encrypted, + Rsa, + Unsupported, + } + let payload = match block.tag() { + "PRIVATE KEY" => Payload::Plain, + "ENCRYPTED PRIVATE KEY" => Payload::Encrypted, + "RSA PRIVATE KEY" => Payload::Rsa, + _ => Payload::Unsupported, + }; + let der = Zeroizing::new(block.into_contents()); + // RFC 7468 sections 10/11 define distinct PKCS#8 labels. Section 2 + // permits reinterpretation, but our protected-key contract forbids it: + // https://www.rfc-editor.org/rfc/rfc7468#section-2 + match payload { + Payload::Encrypted => { + EncryptedPrivateKeyInfoRef::try_from(der.as_slice()) + .map_err(|_| KeyStoreError::ProtectedContainer)?; + } + Payload::Plain => { + PrivateKeyInfoRef::try_from(der.as_slice()) + .map_err(|_| KeyStoreError::Selection("invalid PRIVATE KEY payload"))?; + } + Payload::Rsa => preflight_rsa_pkcs1_components(&der)?, + Payload::Unsupported => { + return Err(KeyStoreError::Selection("unsupported private PEM label")); } - _ => Err(KeyStoreError::Selection("unsupported private PEM label")), } + 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(()) } /// Import a bounded PKCS#12 bundle from caller-owned bytes. The key may @@ -1456,13 +1492,20 @@ impl KeyInventory { 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.is_empty() { + return Err(KeyStoreError::Selection("empty key name")); + } + if self.entry_count >= resources.max_key_candidates { + return Err(import_resource_limit( + crate::policy::resource_name::KEY_CANDIDATES, + resources.max_key_candidates, )); } if name.len() > resources.max_external_resource_bytes { - return Err(KeyStoreError::Selection("key name exceeds resource limit")); + return Err(import_resource_limit( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_bytes, + )); } self.check_material_capacity(name.len(), resources)?; if self.symmetric_keys.iter().any(|key| key.name == name) @@ -1479,13 +1522,16 @@ impl KeyInventory { length: usize, resources: &ResourcePolicy, ) -> Result { - let total = self - .material_bytes - .checked_add(length) - .ok_or(KeyStoreError::Selection("key material size overflow"))?; + let total = self.material_bytes.checked_add(length).ok_or_else(|| { + import_resource_limit( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_total_bytes, + ) + })?; if total > resources.max_external_resource_total_bytes { - return Err(KeyStoreError::Selection( - "key material total exceeds resource limit", + return Err(import_resource_limit( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_total_bytes, )); } Ok(total) @@ -1510,8 +1556,9 @@ impl KeyInventory { ) -> Result<(), KeyStoreError> { ensure_resource_policy(resources)?; if der.len() > resources.max_external_resource_bytes { - return Err(KeyStoreError::Selection( - "certificate exceeds resource limit", + return Err(import_resource_limit( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_bytes, )); } let (rest, _) = X509Certificate::from_der(&der) @@ -1520,7 +1567,10 @@ impl KeyInventory { return Err(KeyStoreError::Selection("invalid X.509 certificate")); } if self.entry_count >= resources.max_key_candidates { - return Err(KeyStoreError::Selection("key candidate limit exceeded")); + return Err(import_resource_limit( + crate::policy::resource_name::KEY_CANDIDATES, + resources.max_key_candidates, + )); } self.reserve_material(der.len(), resources)?; if trusted_anchor { @@ -1540,7 +1590,10 @@ impl KeyInventory { ) -> Result<(), KeyStoreError> { ensure_resource_policy(resources)?; if der.len() > resources.max_external_resource_bytes { - return Err(KeyStoreError::Selection("CRL exceeds resource limit")); + return Err(import_resource_limit( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_bytes, + )); } let (rest, _) = x509_parser::revocation_list::CertificateRevocationList::from_der(&der) .map_err(|_| KeyStoreError::Selection("invalid X.509 CRL"))?; @@ -1548,7 +1601,10 @@ impl KeyInventory { return Err(KeyStoreError::Selection("invalid X.509 CRL")); } if self.entry_count >= resources.max_key_candidates { - return Err(KeyStoreError::Selection("key candidate limit exceeded")); + return Err(import_resource_limit( + crate::policy::resource_name::KEY_CANDIDATES, + resources.max_key_candidates, + )); } self.reserve_material(der.len(), resources)?; self.crls.push(der); @@ -1563,11 +1619,16 @@ impl KeyInventory { ) -> 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", + if bytes.len() > resources.max_external_resource_bytes { + return Err(import_resource_limit( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_bytes, + )); + } + if bytes.len() > resources.max_external_resource_total_bytes { + return Err(import_resource_limit( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_total_bytes, )); } let settings = DocumentParseSettings::from_policy(policy.xml_input_policy(), resources) @@ -1580,9 +1641,15 @@ impl KeyInventory { .min(resources.max_external_resource_bytes), Some(&budget), ) - .map_err(|error| KeyStoreError::Invalid(error.to_string()))?; + .map_err(|error| match error.into_policy_violation(settings) { + Ok(violation) => KeyStoreError::Policy(violation), + 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()))?; + .map_err(|error| match error.into_policy_violation(settings) { + Ok(violation) => KeyStoreError::Policy(violation), + 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())); @@ -1595,8 +1662,9 @@ impl KeyInventory { 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(), + return Err(import_resource_limit( + crate::policy::resource_name::KEY_CANDIDATES, + resources.max_key_candidates, )); } entry_count += 1; @@ -1740,14 +1808,19 @@ impl KeyInventory { // 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", + return Err(import_resource_limit( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_total_bytes, )); } Ok(store) } } +fn import_resource_limit(resource: &'static str, maximum: usize) -> KeyStoreError { + crate::policy::PolicyViolation::ResourceLimitExceeded { resource, maximum }.into() +} + fn check_operation_material_size( length: usize, resources: &ResourcePolicy, @@ -1961,7 +2034,10 @@ fn preflight_dsa_pkcs8_components(info: &PrivateKeyInfoRef<'_>) -> Result<(), Ke fn single_pem_block(bytes: &[u8], maximum: usize) -> Result { if bytes.len() > maximum { - return Err(KeyStoreError::Selection("PEM key exceeds resource limit")); + return Err(import_resource_limit( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum, + )); } let text = std::str::from_utf8(bytes) .map_err(|_| KeyStoreError::Selection("PEM key is not ASCII text"))? @@ -2583,10 +2659,12 @@ mod tests { #[test] fn pkcs12_imports_share_candidate_budget() { - // A key plus its retained certificate consumes two inventory slots. + // Two ContentInfos and two SafeBags cost four inspections. The retained + // key/certificate use two inventory slots, leaving too little work for + // another bundle even though two more retained slots would fit. let bundle = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); let resources = ResourcePolicy { - max_key_candidates: 3, + max_key_candidates: 4, ..ResourcePolicy::default() }; let mut inventory = KeyInventory::default(); @@ -2743,7 +2821,12 @@ mod tests { KeyUsages::SIGN, &limited, ), - Err(KeyStoreError::Selection(_)) + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + .. + } + )) )); let limited_kdf = ResourcePolicy { max_key_import_kdf_work: 1, @@ -2839,7 +2922,8 @@ mod tests { #[test] fn xml_store_charges_retained_decoded_material() { - // DSA retains public components and a derived private PKCS#8 buffer. + // DSA retains public components, private PKCS#8 and three name copies. + // A long valid name makes retained bytes exceed the encoded source. 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', "") + source[start..end] + .replace('\n', "") + .replace("test-dsa", &"name".repeat(512)) ); let inventory = KeyInventory::from_xml_bytes( xml.as_bytes(), @@ -2858,6 +2944,27 @@ mod tests { 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()); + assert!( + retained > xml.len(), + "fixture must distinguish retained bytes from source bytes" + ); + let resources = ResourcePolicy { + max_external_resource_total_bytes: retained - 1, + ..ResourcePolicy::default() + }; + assert!(matches!( + KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(resources), + XmlBackend::default() + ), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + .. + } + )) + )); } #[test] @@ -3005,6 +3112,74 @@ mod tests { } } + #[test] + fn xml_store_import_limits_preserve_policy_errors() { + // Limit denials must not look like malformed stores or candidate misses. + let xml = format!( + "keyc2VjcmV0" + ); + let two = xml.replace( + "", + &format!( + "{} ", + xml[xml.find("").expect("fixture has Keys end tag")] + .replace(">key<", ">other<") + ), + ); + for (bytes, resources, expected) in [ + ( + xml.as_bytes(), + ResourcePolicy { + max_external_resource_bytes: xml.len() - 1, + ..ResourcePolicy::default() + }, + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + ), + ( + xml.as_bytes(), + ResourcePolicy { + max_external_resource_total_bytes: xml.len() - 1, + ..ResourcePolicy::default() + }, + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + ), + ( + two.as_bytes(), + ResourcePolicy { + max_key_candidates: 1, + ..ResourcePolicy::default() + }, + crate::policy::resource_name::KEY_CANDIDATES, + ), + ] { + assert!( + matches!(KeyInventory::from_xml_bytes(bytes, &xml_policy(resources), XmlBackend::default()), + Err(KeyStoreError::Policy(crate::policy::PolicyViolation::ResourceLimitExceeded { resource, .. })) if resource == expected) + ); + } + let mut inventory = KeyInventory::default(); + let resources = ResourcePolicy { + max_external_resource_total_bytes: 3, + ..ResourcePolicy::default() + }; + assert!(matches!( + inventory.add_symmetric( + "key".into(), + SymmetricKeyKind::Hmac, + vec![7; 32], + KeyUsages::SIGN, + &resources + ), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + .. + } + )) + )); + } + #[test] fn xml_store_enforces_all_parser_resource_limits() { // Import must not skip the depth, namespace or cumulative work limits. @@ -3031,8 +3206,8 @@ mod tests { &xml_policy(resources), XmlBackend::default() ) - .is_err(), - "parser limit must apply to key stores" + .is_err_and(|error| matches!(error, KeyStoreError::Policy(_))), + "parser policy denial must retain its type in key stores" ); } } @@ -3302,6 +3477,32 @@ mod tests { ); } + #[test] + fn private_pem_label_cannot_enable_der_fallback() { + // A protected label must never import plaintext, even with a password. + let plain = pem::parse(include_bytes!( + "../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("fixture") + .into_contents(); + let mislabeled = pem::encode(&pem::Pem::new("ENCRYPTED PRIVATE KEY", plain)); + for password in [None, Some(b"ignored".as_slice())] { + let mut inventory = KeyInventory::default(); + assert!(matches!( + inventory.add_private_pem( + "key".into(), + mislabeled.as_bytes(), + password, + KeyUsages::SIGN, + &ResourcePolicy::default() + ), + Err(KeyStoreError::ProtectedContainer) + )); + assert!(inventory.private_keys.is_empty()); + assert_eq!(inventory.material_bytes, 0); + } + } + #[test] fn encrypted_pkcs8_requires_correct_password_without_plaintext_fallback() { // Wrong passwords must not retry another format or leave a partial @@ -3611,8 +3812,12 @@ mod tests { assert!( matches!( import(&mut inventory, "second"), - Err(KeyStoreError::Selection( - "key material total exceeds resource limit" + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + .. + } )) ), "second PEM input must exceed aggregate budget" @@ -4677,8 +4882,11 @@ mod tests { }; assert!(matches!( first.extend(second, &constrained), - Err(KeyStoreError::Selection( - "key material total exceeds resource limit" + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + .. + } )) )); assert_eq!(first.entry_count(), 1); diff --git a/src/key_manager/pkcs12_import.rs b/src/key_manager/pkcs12_import.rs index 2a6781af..04c8b14a 100644 --- a/src/key_manager/pkcs12_import.rs +++ b/src/key_manager/pkcs12_import.rs @@ -31,8 +31,7 @@ struct Budget<'a> { limits: &'a Limits, work: usize, memory: usize, - bags: usize, - infos: usize, + candidates: usize, } fn denial(resource: &'static str, maximum: usize) -> KeyStoreError { @@ -49,8 +48,7 @@ impl<'a> Budget<'a> { limits, work: 0, memory: 0, - bags: 0, - infos: 0, + candidates: 0, } } @@ -71,15 +69,14 @@ impl<'a> Budget<'a> { 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 { + fn count(&mut self) -> Result<()> { + if self.candidates >= self.limits.candidates { return Err(denial( resource_name::KEY_CANDIDATES, self.limits.candidates, )); } - *count += 1; + self.candidates += 1; Ok(()) } @@ -153,13 +150,36 @@ fn tlv(bytes: &[u8], depth: usize) -> Result<(Tlv<'_>, &[u8])> { return malformed(); } let tag = bytes[0]; - if tag & 0x1f == 0x1f || tag == 0 { + if tag == 0 { return malformed(); } - let mut start = 2; + let mut identifier_end = 1; + if tag & 0x1f == 0x1f { + // X.690 (2021) 8.1.2.4: high tags use nonzero base-128 groups. + // Unknown attribute tags need framing, not an integer materialization; + // scanning borrowed octets also accepts numbers wider than usize. + // https://www.itu.int/rec/T-REC-X.690-202102-I/en + let first = bytes[identifier_end]; + if first & 0x7f == 0 || first < 31 { + return malformed(); + } + loop { + let byte = *bytes + .get(identifier_end) + .ok_or(KeyStoreError::ProtectedContainer)?; + identifier_end += 1; + if byte & 0x80 == 0 { + break; + } + } + } + let length_octet = *bytes + .get(identifier_end) + .ok_or(KeyStoreError::ProtectedContainer)?; + let mut start = identifier_end + 1; let end; let consumed; - if bytes[1] == 0x80 { + if length_octet == 0x80 { if tag & 0x20 == 0 { return malformed(); } @@ -173,7 +193,7 @@ fn tlv(bytes: &[u8], depth: usize) -> Result<(Tlv<'_>, &[u8])> { remaining = tlv(remaining, depth + 1)?.1; } } else { - let mut length = usize::from(bytes[1]); + let mut length = usize::from(length_octet); if length & 0x80 != 0 { let count = length & 0x7f; if count == 0 || count > core::mem::size_of::() || count > bytes.len() - start { @@ -767,7 +787,7 @@ fn safe_contents( } let mut safe = Reader::sequence(bytes)?; while !safe.0.is_empty() { - budget.count(true)?; + budget.count()?; let mut bag = Reader(safe.take(0x30)?.value); let oid = bag.oid()?; let value = bag.take(0xa0)?.value; @@ -848,7 +868,7 @@ fn walk_safe( ) -> Result<()> { let mut safe = Reader::sequence(bytes)?; while !safe.0.is_empty() { - budget.count(false)?; + budget.count()?; let (oid, content) = content_info(safe.take(0x30)?)?; if oid == DATA { let data = octets(content, 4, budget)?; @@ -1037,6 +1057,43 @@ mod tests { } } + #[test] + fn high_tag_attributes_are_valid_ber() { + // Unknown attributes are ignorable, but their high-number identifiers + // must remain well framed for primitive, constructed and indefinite BER. + for value in [ + vec![0x9f, 31, 1, 7], + vec![0xbf, 0x81, 0, 2, 4, 0], + vec![0xbf, 31, 0x80, 4, 0, 0, 0], + vec![ + 0x9f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, 0, + ], + ] { + let key = sequence(&[ + oid(pkcs12::PKCS_12_KEY_BAG_OID), + encoded(0xa0, &[0x30, 0]), + encoded( + 0x31, + &sequence(&[oid(Oid::new_unwrap("1.2.3.4")), encoded(0x31, &value)]), + ), + ]); + assert!(prepare(&pfx(&[data(&sequence(&[key]))]), &limits(64)).is_ok()); + } + for value in [ + &[0x9f, 0, 0][..], + &[0x9f, 0x80, 31, 0], + &[0x9f, 30, 0], + &[0x9f, 0x81], + &[0x9f, 31], + &[0x9f, 31, 0x80, 0, 0], + ] { + assert!( + tlv(value, 0).is_err(), + "malformed identifier/length {value:?}" + ); + } + } + #[test] fn malformed_bag_attributes_are_rejected_before_password() { // Optional attributes are still ASN.1 Attribute records, not an @@ -1055,6 +1112,23 @@ mod tests { assert!(Reader(&[2, 2, 0, 3]).integer().is_err()); } + #[test] + fn content_infos_and_bags_share_candidate_count() { + // Empty ContentInfos still inspect a source; bags cannot start a new allowance. + let key = bag(pkcs12::PKCS_12_KEY_BAG_OID, &[0x30, 0]); + let bytes = pfx(&[data(&sequence(&[])), data(&sequence(&[key]))]); + assert!(matches!( + prepare(&bytes, &limits(2)), + Err(KeyStoreError::Policy( + PolicyViolation::ResourceLimitExceeded { + resource: resource_name::KEY_CANDIDATES, + maximum: 2 + } + )) + )); + assert!(prepare(&bytes, &limits(3)).is_ok()); + } + #[test] fn nested_bags_share_candidate_count() { // A nested SafeContentsBag is not a reset of the outer bag budget. diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 69288094..e8764547 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -471,27 +471,31 @@ pub(crate) enum ResolutionScope { /// Parser cardinality preflights prevent expensive materialization, but do not /// replace this runtime accounting: embedded and indirect candidates both /// consume resolver work when inspected. -#[derive(Clone, Copy)] -pub(crate) struct InspectedKeyCandidateBudget { +/// Cumulative source-inspection work shared across one key resolution operation. +/// Reuse this budget when resolving multiple caller-owned candidate records. +#[derive(Debug)] +pub struct InspectedKeyCandidateBudget { maximum: usize, attempted: usize, } impl InspectedKeyCandidateBudget { - pub(crate) fn new(maximum: usize) -> Self { + /// Start an empty budget; resolution also enforces the active policy ceiling. + #[must_use] + pub fn new(maximum: usize) -> Self { Self { maximum, attempted: 0, } } - pub(crate) fn charge(&mut self) -> Result<(), DsigError> { + /// Reserve one direct-key inspection before copying or decoding it. + pub fn charge(&mut self) -> Result<(), DsigError> { self.charge_many(1) } pub(crate) fn charge_many(&mut self, count: usize) -> Result<(), DsigError> { - debug_assert!(self.attempted <= self.maximum); - if count > self.maximum - self.attempted { + if self.attempted > self.maximum || count > self.maximum - self.attempted { return Err(crate::policy::PolicyViolation::ResourceLimit { resource: crate::policy::resource_name::KEY_CANDIDATES, maximum: self.maximum, @@ -499,6 +503,7 @@ impl InspectedKeyCandidateBudget { } .into()); } + debug_assert!(self.attempted <= self.maximum); self.attempted += count; Ok(()) } @@ -540,7 +545,10 @@ pub(crate) fn validate_key_info_source_permissions( } impl DefaultKeyResolver { - pub(crate) fn resolve_with_candidate_budget( + /// Resolve another candidate without resetting aggregate inspection work. + /// The caller must keep the same budget throughout an operation, including + /// failed attempts; policy denials must not be treated as candidate misses. + pub fn resolve_with_candidate_budget( &self, key_info: Option<&KeyInfo>, algorithm: SignatureAlgorithm, @@ -548,6 +556,10 @@ impl DefaultKeyResolver { provider: &dyn crate::provider::CryptoProvider, candidate_budget: &mut InspectedKeyCandidateBudget, ) -> Result>, DsigError> { + candidate_budget.maximum = candidate_budget + .maximum + .min(policy.resources.max_key_candidates); + candidate_budget.charge_many(0)?; self.resolve_with_trust( key_info, algorithm, @@ -1524,6 +1536,67 @@ mod tests { use super::*; + #[test] + fn shared_candidate_budget_cannot_relax_active_policy() { + // A caller-created large budget cannot override policy, nor can a + // later tighter snapshot forget work already performed. + let resolver = DefaultKeyResolver::new(KeyResolverConfig::default()); + let info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("missing".into())], + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.resources.max_key_candidates = 1; + let mut budget = InspectedKeyCandidateBudget::new(64); + assert!( + resolver + .resolve_with_candidate_budget( + Some(&info), + SignatureAlgorithm::RsaSha256, + &policy, + crate::provider::default_provider(), + &mut budget + ) + .expect("first candidate fits the active policy") + .is_none() + ); + assert!(matches!( + resolver.resolve_with_candidate_budget( + Some(&info), + SignatureAlgorithm::RsaSha256, + &policy, + crate::provider::default_provider(), + &mut budget + ), + Err(DsigError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + maximum: 1, + actual: 2, + .. + } + )) + )); + let mut spent = InspectedKeyCandidateBudget::new(64); + spent + .charge_many(2) + .expect("initial budget admits two candidates"); + assert!(matches!( + resolver.resolve_with_candidate_budget( + None, + SignatureAlgorithm::RsaSha256, + &policy, + crate::provider::default_provider(), + &mut spent + ), + Err(DsigError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + maximum: 1, + actual: 2, + .. + } + )) + )); + } + #[test] fn xml_rsa_components_are_bounded_before_bigint_decode() { // KeyValue import must reject oversized decoded modulus before conversion. diff --git a/src/xmldsig/mod.rs b/src/xmldsig/mod.rs index 264ca52b..4bedc865 100644 --- a/src/xmldsig/mod.rs +++ b/src/xmldsig/mod.rs @@ -70,8 +70,8 @@ mod xpath; pub use builder::{ReferenceBuilder, SignatureBuilder, SignatureBuilderError}; pub use digest::{DigestAlgorithm, compute_digest, compute_digest_with_provider, constant_time_eq}; pub use keys::{ - DefaultKeyResolver, HmacSha1VerificationKey, HmacVerificationKey, KeyResolutionError, - KeyResolverConfig, VerificationKey, + DefaultKeyResolver, HmacSha1VerificationKey, HmacVerificationKey, InspectedKeyCandidateBudget, + KeyResolutionError, KeyResolverConfig, VerificationKey, }; pub use parse::{ KeyInfo, KeyInfoSource, KeyValueInfo, ParseError, Reference, RetrievalMethodTransforms, diff --git a/tools/xmlsec1/src/commands.rs b/tools/xmlsec1/src/commands.rs index 9eafd5c2..2b2a32cc 100644 --- a/tools/xmlsec1/src/commands.rs +++ b/tools/xmlsec1/src/commands.rs @@ -25,10 +25,11 @@ use xml_sec::{ provider::{CryptoProvider, default_provider}, xmldsig::{ DefaultKeyResolver, DigestAlgorithm, DsigError, DsigStatus, FailureReason, HmacSigningKey, - HmacVerificationKey, KeyInfo, KeyInfoSource, KeyInfoWriter, KeyResolver, KeyResolverConfig, - KeyValueInfo, ReferenceResult, SignContext, SignatureAlgorithm, SignatureTemplateSelection, - SigningKey, SigningPublicKeyInfo, UriTypeSet, VerificationKey, VerifyContext, VerifyResult, - VerifyingKey, X509CertificateKeyInfoWriter, XPathHereSemantics, parse_key_info, + HmacVerificationKey, InspectedKeyCandidateBudget, KeyInfo, KeyInfoSource, KeyInfoWriter, + KeyResolver, KeyResolverConfig, KeyValueInfo, ReferenceResult, SignContext, + SignatureAlgorithm, SignatureTemplateSelection, SigningKey, SigningPublicKeyInfo, + UriTypeSet, VerificationKey, VerifyContext, VerifyResult, VerifyingKey, + X509CertificateKeyInfoWriter, XPathHereSemantics, parse_key_info, uri::UriReferenceResolver, validate_signing_key, x509_certificate_matches_selectors, }, xmlenc::{ @@ -1895,6 +1896,9 @@ impl KeyResolver for CandidateVerificationResolver { provider: &dyn CryptoProvider, ) -> Result>, DsigError> { validate_verification_candidate_count(self.candidates.len(), policy)?; + policy.validate()?; + let mut candidate_budget = + InspectedKeyCandidateBudget::new(policy.resources.max_key_candidates); let document_crls = key_info .into_iter() .flat_map(|info| &info.sources) @@ -1902,9 +1906,8 @@ impl KeyResolver for CandidateVerificationResolver { KeyInfoSource::X509Data(info) => Some(info.crls.as_slice()), _ => None, }) - .flatten() - .cloned() - .collect::>(); + .flatten(); + let has_document_crls = document_crls.clone().next().is_some(); let mut certificate_policy = policy.clone(); if !self.has_trusted_certificates { // A caller-pinned certificate without a separate trust anchor is @@ -1918,32 +1921,39 @@ impl KeyResolver for CandidateVerificationResolver { for candidate in &self.candidates { let key = match candidate { ExplicitVerificationCandidate::Direct(key) => { + candidate_budget.charge()?; Some(Box::new(key.clone()) as Box) } ExplicitVerificationCandidate::Hmac(key) => { + candidate_budget.charge()?; Some(Box::new(key.clone()) as Box) } ExplicitVerificationCandidate::Certificate(info) => { - let mut candidate = info.clone(); - if !document_crls.is_empty() - && let Some(KeyInfoSource::X509Data(x509)) = candidate + let mut candidate = Cow::Borrowed(info); + if has_document_crls + && let Some(index) = info .sources - .iter_mut() - .find(|source| matches!(source, KeyInfoSource::X509Data(_))) + .iter() + .position(|source| matches!(source, KeyInfoSource::X509Data(_))) + && let KeyInfoSource::X509Data(x509) = + &mut candidate.to_mut().sources[index] { // The explicit certificate remains the sole identity // source. Only revocation evidence crosses from the // untrusted document KeyInfo into its candidate path. - x509.crls.extend(document_crls.iter().cloned()); + x509.crls.extend(document_crls.clone().cloned()); } - match self.certificate_resolver.resolve_with_policy_and_provider( - Some(&candidate), + match self.certificate_resolver.resolve_with_candidate_budget( + Some(candidate.as_ref()), algorithm, &certificate_policy, provider, + &mut candidate_budget, ) { Ok(key) => key, - Err(error) if self.lax_key_search => { + Err(error) + if self.lax_key_search && !matches!(error, DsigError::Policy(_)) => + { last_error = Some(error); continue; } @@ -1954,7 +1964,9 @@ impl KeyResolver for CandidateVerificationResolver { if let Some(key) = key { match key.validate_policy(policy) { Ok(()) => resolved.push(key), - Err(error) if self.lax_key_search => last_error = Some(error), + Err(error) if self.lax_key_search && !matches!(error, DsigError::Policy(_)) => { + last_error = Some(error) + } Err(error) => return Err(error), } } @@ -4816,6 +4828,58 @@ mod tests { assert_eq!(second_calls.get(), 1); } + #[test] + fn stored_verification_sources_share_candidate_budget() { + // Lax search must not reset source-inspection work per imported entry, + // or swallow the denial because an earlier candidate resolved. + let mut info = KeyInfo::default(); + info.sources = vec![ + KeyInfoSource::KeyName("first".into()), + KeyInfoSource::KeyValue(KeyValueInfo::Unsupported { + namespace: None, + local_name: "unsupported".into(), + }), + ]; + let mut inventory = KeyInventory::default(); + inventory + .add_public_pem( + "valid".into(), + include_bytes!("../../../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"), + &ResourcePolicy::default(), + ) + .unwrap(); + let valid = inventory.public_keys()[0].key_info.clone(); + for info in [info, valid] { + let resolver = CandidateVerificationResolver::new( + vec![ + ExplicitVerificationCandidate::Certificate(info.clone()), + ExplicitVerificationCandidate::Certificate(info), + ], + ConfiguredCertificates::default(), + true, + false, + ); + let mut policy = VerificationPolicy::default(); + policy.resources.max_key_candidates = 3; + assert!(matches!(resolver.resolve_with_policy_and_provider(None, + SignatureAlgorithm::RsaSha256, &policy, default_provider()), + Err(DsigError::Policy(xml_sec::policy::PolicyViolation::ResourceLimit { + resource, maximum: 3, .. + })) if resource == "key candidates")); + policy.resources.max_key_candidates = 4; + assert!( + resolver + .resolve_with_policy_and_provider( + None, + SignatureAlgorithm::RsaSha256, + &policy, + default_provider() + ) + .is_ok() + ); + } + } + #[test] fn verification_candidate_collection_obeys_trust_budget() { // Lax lookup must not turn caller-provided key files into unbounded diff --git a/tools/xmlsec1/tests/process_contract.rs b/tools/xmlsec1/tests/process_contract.rs index 78a16d86..a3577edc 100644 --- a/tools/xmlsec1/tests/process_contract.rs +++ b/tools/xmlsec1/tests/process_contract.rs @@ -47,6 +47,45 @@ fn project_root() -> &'static Path { Path::new(env!("CARGO_MANIFEST_DIR")) } +#[test] +fn mislabeled_encrypted_pem_cannot_sign() { + // Generic private-key loading must honor PEM protection labels, not retry + // plaintext DER; rejection must leave no signed output on disk. + let temp = tempfile::tempdir().unwrap(); + let private = temp.path().join("private.pem"); + let template = temp.path().join("template.xml"); + let signed = temp.path().join("signed.xml"); + let block = pem::parse( + fs::read(project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem")).unwrap(), + ) + .unwrap(); + fs::write( + &private, + pem::encode(&pem::Pem::new( + "ENCRYPTED PRIVATE KEY", + block.into_contents(), + )), + ) + .unwrap(); + fs::write(&template, signature_template_without_key_info()).unwrap(); + for password in [None, Some("unused-password")] { + let mut command = Command::new(binary()); + command.args(["sign", "--privkey-pem"]).arg(&private); + if let Some(password) = password { + command.args(["--pwd", password]); + } + let result = command + .arg("--output") + .arg(&signed) + .arg(&template) + .output() + .unwrap(); + assert!(!result.status.success()); + assert!(!signed.exists()); + assert!(!String::from_utf8_lossy(&result.stderr).contains("unused-password")); + } +} + #[test] fn donor_pkcs12_decrypts_and_wrong_password_fails_closed() { // The PHAOS bundle and ciphertext are independent xmlsec1 oracle inputs. From ce1ef0778efdfbd36705cf65014bcf3d1ce363f3 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 1 Oct 2026 20:45:51 +0300 Subject: [PATCH 3/9] fix(keys): preserve bounded import semantics Normalize BER private-key containers before DER decoding, accept versioned CMS metadata, and keep protected-envelope and policy failures terminal. Derive shared candidate accounting from policy and bound DSA components before bigint work. Add boundary, malformed-container, public-inventory, and CLI regression coverage; decode traditional EC envelopes once across curve selection. --- docs/key-management.md | 12 + src/key_manager.rs | 3 +- src/key_manager/pkcs12_import.rs | 282 +++++++++++++++++++++++- src/policy.rs | 10 + src/xmldsig/keys.rs | 73 +++++- tools/xmlsec1/src/commands.rs | 31 ++- tools/xmlsec1/src/key_material.rs | 133 +++++++---- tools/xmlsec1/tests/process_contract.rs | 45 ++++ 8 files changed, 523 insertions(+), 66 deletions(-) diff --git a/docs/key-management.md b/docs/key-management.md index 647f6e6b..84e52d3e 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -102,6 +102,12 @@ denial is not a password error. Lax CLI verification also shares one inspection budget across all stored candidates and stops on a policy denial even after another candidate resolved. Custom bag attributes accept BER high-tag-number identifiers (X.690 section 8.1.2.4) without retaining their values. +Shared XMLDSig candidate accounting is constructed from the operation's +`VerificationPolicy`, not a separate caller-supplied numeric limit. +BER PrivateKeyInfo framing and constructed private-key OCTET STRINGs are +normalized to bounded PKCS#8 DER before storage. CMS EncryptedData accepts +unprotected attributes with version 2, while requiring version 0 without them +(RFC 5652 section 8); metadata framing is validated before password processing. 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 @@ -137,6 +143,12 @@ 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. +Stored RSA recipient policy denials are terminal even with `--lax-key-search`; +they are never downgraded to a candidate mismatch. Traditional encrypted RSA, +DSA, and EC PEM also fail terminally when CBC padding succeeds but the decoded +key is invalid, because padding does not authenticate the decrypted bytes. +Traditional EC PEM is decoded once before selecting a curve; all supported +curve decoders borrow the same zeroizing plaintext buffer. 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 diff --git a/src/key_manager.rs b/src/key_manager.rs index 05612474..fa9c6f79 100644 --- a/src/key_manager.rs +++ b/src/key_manager.rs @@ -354,8 +354,7 @@ impl<'a> KeyResolver for InventoryVerificationResolver<'a> { } let mut candidate: Option<&StoredPublicKey> = None; let mut secret_candidate: Option<&StoredSymmetricKey> = None; - let mut inspected_candidates = - InspectedKeyCandidateBudget::new(policy.resources.max_key_candidates); + let mut inspected_candidates = InspectedKeyCandidateBudget::new(policy); for name in key_info .into_iter() .flat_map(|info| &info.sources) diff --git a/src/key_manager/pkcs12_import.rs b/src/key_manager/pkcs12_import.rs index 04c8b14a..20672cf8 100644 --- a/src/key_manager/pkcs12_import.rs +++ b/src/key_manager/pkcs12_import.rs @@ -693,11 +693,25 @@ fn encrypted_content<'a>( return malformed(); } let mut data = Reader(encoded.value); - if data.integer()? != 0 { - return malformed(); - } + let version = data.integer()?; let mut info = Reader(data.take(0x30)?.value); + let attributes_present = !data.0.is_empty(); + if attributes_present { + let attributes = data.take(0xa1)?; + // UnprotectedAttributes is SET SIZE (1..MAX) OF Attribute (6.1). + // https://www.rfc-editor.org/rfc/rfc5652#section-6.1 + if attributes.value.is_empty() { + return malformed(); + } + validate_attributes(Reader(attributes.value))?; + } data.finish()?; + // RFC 5652 8: version is 2 with unprotectedAttrs, otherwise 0. + // Unknown metadata does not affect key selection, but must be well framed. + // https://www.rfc-editor.org/rfc/rfc5652#section-8 + if version != if attributes_present { 2 } else { 0 } { + return malformed(); + } if info.oid()? != DATA { return malformed(); } @@ -762,10 +776,11 @@ fn validate_attribute_values(mut bytes: &[u8], depth: usize) -> Result<()> { } fn validate_attributes(mut attributes: Reader<'_>) -> Result<()> { - // RFC 7292 4.2 defines each optional PKCS12Attribute as an OID and + // RFC 7292 4.2 and RFC 5652 10.2.1 define attributes 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 + // https://www.rfc-editor.org/rfc/rfc5652#section-10.2.1 while !attributes.0.is_empty() { let mut attribute = Reader(attributes.take(0x30)?.value); attribute.oid()?; @@ -775,6 +790,142 @@ fn validate_attributes(mut attributes: Reader<'_>) -> Result<()> { Ok(()) } +fn der_header_length(length: usize) -> usize { + if length < 128 { + 2 + } else { + 2 + (usize::BITS - length.leading_zeros()).div_ceil(8) as usize + } +} + +fn append_der_header(output: &mut Vec, tag: u8, length: usize) { + output.push(tag); + if length < 128 { + output.push(length as u8); + } else { + let bytes = length.to_be_bytes(); + let first = bytes + .iter() + .position(|byte| *byte != 0) + .unwrap_or(bytes.len()); + output.push(0x80 | (bytes.len() - first) as u8); + output.extend_from_slice(&bytes[first..]); + } +} + +// AlgorithmIdentifier parameters used by supported keys are primitive values +// or a SEQUENCE of integers (DSA). Normalize framing without a heap object tree. +fn parameter_length(value: Tlv<'_>, depth: usize) -> Result { + let length = parameter_body_length(value, depth)?; + length + .checked_add(der_header_length(length)) + .ok_or(KeyStoreError::ProtectedContainer) +} + +fn parameter_body_length(value: Tlv<'_>, depth: usize) -> Result { + if depth >= crate::hard_limits::PKCS12_NESTING_CEILING || value.tag & 0x1f == 0x1f { + return malformed(); + } + let mut length = value.value.len(); + if value.tag & 0x20 != 0 { + if value.tag != 0x30 { + return malformed(); + } + length = 0; + let mut children = value.value; + while !children.is_empty() { + let (child, rest) = tlv(children, depth + 1)?; + length = length + .checked_add(parameter_length(child, depth + 1)?) + .ok_or(KeyStoreError::ProtectedContainer)?; + children = rest; + } + } + Ok(length) +} + +fn append_parameter(output: &mut Vec, value: Tlv<'_>, depth: usize) -> Result<()> { + let body = parameter_body_length(value, depth)?; + append_der_header(output, value.tag, body); + if value.tag == 0x30 { + let mut children = value.value; + while !children.is_empty() { + let (child, rest) = tlv(children, depth + 1)?; + append_parameter(output, child, depth + 1)?; + children = rest; + } + } else { + output.extend_from_slice(value.value); + } + Ok(()) +} + +fn normalize_private_key(bytes: &[u8], budget: &mut Budget<'_>) -> Result>> { + use der::Decode as _; + if pkcs8::PrivateKeyInfoRef::from_der(bytes).is_ok() { + return budget.copy(bytes); + } + // RFC 7292 4/4.2.1 permits BER KeyBag PrivateKeyInfo. Rebuild its + // framing and flatten OCTET STRING fragments before DER-only decoding. + // Attributes are ignored by the key decoder, but validated before discard. + // https://www.rfc-editor.org/rfc/rfc7292#section-4.2.1 + let mut key = Reader::sequence(bytes)?; + let version = key.integer()?; + if version > 1 { + return malformed(); + } + let algorithm = key.take(0x30)?; + let (secret, rest) = tlv(key.0, 0)?; + key.0 = rest; + let mut secret_length = 0usize; + octet_visit(secret, 4, 0, &mut |part| { + secret_length = secret_length + .checked_add(part.len()) + .ok_or(KeyStoreError::ProtectedContainer)?; + Ok(()) + })?; + if key.0.first() == Some(&0xa0) { + validate_attributes(Reader(key.take(0xa0)?.value))?; + } + let public = if !key.0.is_empty() { + Some(key.take(0x81)?) + } else { + None + }; + key.finish()?; + if (version == 1) != public.is_some() { + return malformed(); + } + let public_length = public.map_or(0, |value| { + value.value.len() + der_header_length(value.value.len()) + }); + let body_length = 3usize + .checked_add(parameter_length(algorithm, 0)?) + .and_then(|length| length.checked_add(secret_length)) + .and_then(|length| length.checked_add(der_header_length(secret_length))) + .and_then(|length| length.checked_add(public_length)) + .ok_or(KeyStoreError::ProtectedContainer)?; + let length = body_length + .checked_add(der_header_length(body_length)) + .ok_or(KeyStoreError::ProtectedContainer)?; + budget.allocate(length)?; + let mut output = Zeroizing::new(Vec::with_capacity(length)); + append_der_header(&mut output, 0x30, body_length); + output.extend_from_slice(&[2, 1, version as u8]); + append_parameter(&mut output, algorithm, 0)?; + append_der_header(&mut output, 4, secret_length); + octet_visit(secret, 4, 0, &mut |part| { + output.extend_from_slice(part); + Ok(()) + })?; + if let Some(public) = public { + append_der_header(&mut output, 0x81, public.value.len()); + output.extend_from_slice(public.value); + } + pkcs8::PrivateKeyInfoRef::from_der(&output).map_err(|_| KeyStoreError::ProtectedContainer)?; + Ok(output) +} + fn safe_contents( bytes: &[u8], budget: &mut Budget<'_>, @@ -811,9 +962,16 @@ fn safe_contents( } budget.allocate(core::mem::size_of::>>())?; contents.private_keys.reserve_exact(1); - contents - .private_keys - .push(encryption.decrypt(&encrypted, password, budget)?); + let plaintext = encryption.decrypt(&encrypted, password, budget)?; + use der::Decode as _; + let private_key = if pkcs8::PrivateKeyInfoRef::from_der(&plaintext).is_ok() { + plaintext + } else { + let normalized = normalize_private_key(&plaintext, budget)?; + budget.memory -= plaintext.capacity(); + normalized + }; + contents.private_keys.push(private_key); } encrypted.release(budget); encryption.salt.release(budget); @@ -826,7 +984,9 @@ fn safe_contents( } budget.allocate(core::mem::size_of::>>())?; contents.private_keys.reserve_exact(1); - contents.private_keys.push(budget.copy(value)?); + contents + .private_keys + .push(normalize_private_key(value, budget)?); } } else if oid == pkcs12::PKCS_12_CERT_BAG_OID { let mut cert = Reader::sequence(value)?; @@ -1014,6 +1174,112 @@ mod tests { } } + #[test] + fn ber_private_key_info_is_normalized_before_storage() { + // KeyBag inherits the PFX BER contract; an indefinite SEQUENCE and + // constructed OCTET STRING must reach the DER-only key decoder intact. + let der = sequence(&[ + integer(0), + sequence(&[ + oid(rsa::pkcs8::spki::ObjectIdentifier::new_unwrap( + "1.2.840.113549.1.1.1", + )), + encoded(5, &[]), + ]), + encoded(4, b"private"), + ]); + let mut ber = vec![0x30, 0x80]; + ber.extend(integer(0)); + ber.extend(sequence(&[ + oid(Oid::new_unwrap("1.2.840.113549.1.1.1")), + encoded(5, &[]), + ])); + ber.extend([ + 0x24, 0x80, 4, 3, b'p', b'r', b'i', 4, 4, b'v', b'a', b't', b'e', 0, 0, 0, 0, + ]); + let bytes = pfx(&[data(&sequence(&[bag(pkcs12::PKCS_12_KEY_BAG_OID, &ber)]))]); + let limits = limits(64); + let imported = prepare(&bytes, &limits) + .expect("BER preflight") + .decrypt("secret") + .expect("BER key import"); + assert_eq!(&*imported.private_keys[0], &der); + let mut tight = limits; + tight.memory_available = der.len() - 1; + let mut budget = Budget::new(&tight); + assert!(matches!( + normalize_private_key(&ber, &mut budget), + Err(KeyStoreError::Policy(_)) + )); + assert_eq!(budget.memory, 0, "denial precedes output allocation"); + } + + #[test] + fn ber_key_bag_reaches_public_inventory() { + // Public import must normalize the actual key, not merely accept BER + // framing in preflight and fail in the later PKCS#8 decoder. + let private = pem::parse(include_bytes!( + "../../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("PKCS#8 fixture") + .into_contents(); + let sequence_value = tlv(&private, 0).expect("PrivateKeyInfo sequence").0; + let mut ber = vec![0x30, 0x80]; + ber.extend_from_slice(sequence_value.value); + ber.extend_from_slice(&[0, 0]); + let bytes = pfx(&[data(&sequence(&[bag(pkcs12::PKCS_12_KEY_BAG_OID, &ber)]))]); + let mut inventory = crate::key_manager::KeyInventory::default(); + inventory + .add_pkcs12( + "signer".into(), + &bytes, + "secret", + &ResourcePolicy::default(), + ) + .expect("public BER import"); + assert_eq!(inventory.private_keys().len(), 1); + } + + #[test] + fn encrypted_data_version_tracks_unprotected_attributes() { + // CMS version 2 is required exactly when [1] attributes are present. + // Validate them before password processing, including malformed tails. + let algorithm = sequence(&[ + oid(pkcs12::PKCS_12_PBE_WITH_SHAAND3_KEY_TRIPLE_DES_CBC), + sequence(&[encoded(4, b"12345678"), integer(2)]), + ]); + let info = sequence(&[oid(DATA), algorithm, encoded(0x80, &[0; 8])]); + let attribute = sequence(&[ + oid(Oid::new_unwrap("1.2.3.4")), + encoded(0x31, &encoded(4, b"value")), + ]); + for (version, attrs, accepted) in [ + (0, None, true), + (2, Some(attribute.clone()), true), + (0, Some(attribute), false), + (2, None, false), + (2, Some(Vec::new()), false), + (2, Some(vec![0xff]), false), + ] { + let mut parts = vec![integer(version), info.clone()]; + if let Some(attrs) = attrs { + parts.push(encoded(0xa1, &attrs)); + } + let limits = limits(64); + let mut budget = Budget::new(&limits); + let bytes = sequence(&parts); + assert_eq!( + encrypted_content( + tlv(&bytes, 0).expect("EncryptedData sequence").0, + &mut budget + ) + .is_ok(), + accepted, + "version {version}" + ); + } + } + #[test] fn unsupported_algorithms_are_distinct_from_bad_passwords() { // Unsupported capabilities must fail during preflight, rather than diff --git a/src/policy.rs b/src/policy.rs index 66b0baef..65e3fe37 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -1347,6 +1347,16 @@ mod tests { crate::hard_limits::KEY_CANDIDATE_CEILING, |p| &mut p.max_key_candidates, ), + ( + resource_name::KEY_IMPORT_KDF_WORK, + crate::hard_limits::KEY_IMPORT_KDF_WORK_CEILING as usize, + |p| &mut p.max_key_import_kdf_work, + ), + ( + resource_name::KEY_IMPORT_KDF_MEMORY, + crate::hard_limits::KEY_IMPORT_KDF_MEMORY_CEILING, + |p| &mut p.max_key_import_kdf_memory_bytes, + ), ( resource_name::KEY_INFO_REFERENCE_DEPTH, crate::hard_limits::KEY_INFO_REFERENCE_DEPTH_CEILING, diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index e8764547..cc2a4ef8 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -480,11 +480,11 @@ pub struct InspectedKeyCandidateBudget { } impl InspectedKeyCandidateBudget { - /// Start an empty budget; resolution also enforces the active policy ceiling. + /// Start shared accounting derived solely from the operation policy. #[must_use] - pub fn new(maximum: usize) -> Self { + pub fn new(policy: &crate::policy::VerificationPolicy) -> Self { Self { - maximum, + maximum: policy.resources.max_key_candidates, attempted: 0, } } @@ -1211,8 +1211,7 @@ 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); + let mut candidate_budget = InspectedKeyCandidateBudget::new(&policy); self.resolve_with_trust( key_info, algorithm, @@ -1244,8 +1243,7 @@ impl KeyResolver for DefaultKeyResolver { policy: &crate::policy::VerificationPolicy, provider: &dyn crate::provider::CryptoProvider, ) -> Result>, DsigError> { - let mut candidate_budget = - InspectedKeyCandidateBudget::new(policy.resources.max_key_candidates); + let mut candidate_budget = InspectedKeyCandidateBudget::new(policy); self.resolve_with_candidate_budget( key_info, algorithm, @@ -1348,6 +1346,18 @@ fn dsa_key_value_to_spki_der( g: &[u8], y: &[u8], ) -> Result, KeyResolutionError> { + // Bound borrowed unsigned components before any bigint allocation or + // subgroup exponentiation, not only after the SPKI has been produced. + let trim = |bytes: &[u8]| bytes.iter().take_while(|byte| **byte == 0).count(); + let p = &p[trim(p)..]; + let q = &q[trim(q)..]; + let g = &g[trim(g)..]; + let y = &y[trim(y)..]; + for value in [p, q, g, y] { + if value.is_empty() || value.len() > crate::hard_limits::DSA_KEY_COMPONENT_BYTE_CEILING { + return Err(KeyResolutionError::InvalidPublicKey); + } + } let components = dsa::Components::from_components( BoxedUint::from_be_slice_vartime(p), BoxedUint::from_be_slice_vartime(q), @@ -1538,7 +1548,7 @@ mod tests { #[test] fn shared_candidate_budget_cannot_relax_active_policy() { - // A caller-created large budget cannot override policy, nor can a + // A policy-derived budget cannot override policy, nor can a // later tighter snapshot forget work already performed. let resolver = DefaultKeyResolver::new(KeyResolverConfig::default()); let info = KeyInfo { @@ -1546,7 +1556,7 @@ mod tests { }; let mut policy = crate::policy::VerificationPolicy::default(); policy.resources.max_key_candidates = 1; - let mut budget = InspectedKeyCandidateBudget::new(64); + let mut budget = InspectedKeyCandidateBudget::new(&policy); assert!( resolver .resolve_with_candidate_budget( @@ -1575,7 +1585,8 @@ mod tests { } )) )); - let mut spent = InspectedKeyCandidateBudget::new(64); + let mut spent = + InspectedKeyCandidateBudget::new(&crate::policy::VerificationPolicy::default()); spent .charge_many(2) .expect("initial budget admits two candidates"); @@ -1597,6 +1608,48 @@ mod tests { )); } + #[test] + fn dsa_key_value_components_are_bounded_before_bigint_decode() { + // Every unsigned XML component uses the same pre-conversion ceiling; + // redundant zero padding must not inflate bigint precision or change it. + let public = dsa::VerifyingKey::from_public_key_pem(include_str!( + "../../tests/fixtures/keys/dsa/dsa-2048-public.pem" + )) + .expect("DSA fixture"); + let components = public.components(); + let values = [ + components.p().to_be_bytes_trimmed_vartime().to_vec(), + components.q().to_be_bytes_trimmed_vartime().to_vec(), + components.g().to_be_bytes_trimmed_vartime().to_vec(), + public.y().to_be_bytes_trimmed_vartime().to_vec(), + ]; + let expected = dsa_key_value_to_spki_der(&values[0], &values[1], &values[2], &values[3]) + .expect("valid DSA"); + for index in 0..4 { + let mut oversized = values.clone(); + oversized[index] = vec![1; crate::hard_limits::DSA_KEY_COMPONENT_BYTE_CEILING + 1]; + assert!( + dsa_key_value_to_spki_der( + &oversized[0], + &oversized[1], + &oversized[2], + &oversized[3] + ) + .is_err() + ); + } + let padded = values.map(|value| { + let mut padded = vec![0; 1024]; + padded.extend(value); + padded + }); + assert_eq!( + dsa_key_value_to_spki_der(&padded[0], &padded[1], &padded[2], &padded[3]) + .expect("zero padding preserves unsigned DSA value"), + expected + ); + } + #[test] fn xml_rsa_components_are_bounded_before_bigint_decode() { // KeyValue import must reject oversized decoded modulus before conversion. diff --git a/tools/xmlsec1/src/commands.rs b/tools/xmlsec1/src/commands.rs index 2b2a32cc..c0431076 100644 --- a/tools/xmlsec1/src/commands.rs +++ b/tools/xmlsec1/src/commands.rs @@ -1897,8 +1897,7 @@ impl KeyResolver for CandidateVerificationResolver { ) -> Result>, DsigError> { validate_verification_candidate_count(self.candidates.len(), policy)?; policy.validate()?; - let mut candidate_budget = - InspectedKeyCandidateBudget::new(policy.resources.max_key_candidates); + let mut candidate_budget = InspectedKeyCandidateBudget::new(policy); let document_crls = key_info .into_iter() .flat_map(|info| &info.sources) @@ -2387,6 +2386,9 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman selected = Some((entry, candidate)); break; } + Err(error @ CommandError::KeyStore(key_manager::KeyStoreError::Policy(_))) => { + return Err(error); + } Err(error) => last_error = Some(error), } } @@ -4489,8 +4491,9 @@ mod tests { } #[test] - fn cli_lax_store_encryption_skips_ineligible_rsa_key() { - // A 1024-bit donor key precedes a policy-eligible 2048-bit key. + fn cli_lax_store_encryption_preserves_policy_denial() { + // Policy denials are terminal even when a later key is eligible. + // A compliant-only inventory must still complete the round-trip. let temp = tempfile::tempdir().expect("temporary test directory"); let template_path = temp.path().join("template.xml"); let input_path = temp.path().join("input.bin"); @@ -4535,8 +4538,26 @@ mod tests { template_path.to_str().expect("template path is UTF-8"), ]; let mut output = Vec::new(); + assert!(matches!( + execute(invocation(&args), &mut output, &mut Vec::new()), + Err(CommandError::KeyStore(key_manager::KeyStoreError::Policy( + xml_sec::policy::PolicyViolation::KeySize { + operation: "encryption", + key_type: "RSA", + minimum_bits: 2048, + maximum_bits: 8192, + actual_bits: 1024, + } + ))) + )); + assert!(output.is_empty()); + fs::write( + &store_path, + format!("{extra}"), + ) + .expect("compliant-only store"); execute(invocation(&args), &mut output, &mut Vec::new()) - .expect("lax search skips RSA-1024 before RSA-2048"); + .expect("lax search selects the compliant RSA key"); assert!(String::from_utf8_lossy(&output).contains("CipherValue")); let encrypted = temp.path().join("encrypted.xml"); fs::write(&encrypted, &output).expect("write encrypted output"); diff --git a/tools/xmlsec1/src/key_material.rs b/tools/xmlsec1/src/key_material.rs index d5092f99..d9213ea8 100644 --- a/tools/xmlsec1/src/key_material.rs +++ b/tools/xmlsec1/src/key_material.rs @@ -526,22 +526,26 @@ fn decode_ecdsa_signing_key( format: PrivateKeyFormat, password: Option<&[u8]>, ) -> Result, KeyMaterialError> { - decode_ecdsa_curve::(path, bytes, format, password) - .or_else(|_| decode_ecdsa_curve::(path, bytes, format, password)) - .or_else(|_| decode_ecdsa_curve::(path, bytes, format, password)) + if pkcs8_container_kind(bytes, format).is_some() { + return decode_pkcs8_signing_key::(path, bytes, format, password) + .or_else(|_| { + decode_pkcs8_signing_key::(path, bytes, format, password) + }) + .or_else(|_| { + decode_pkcs8_signing_key::(path, bytes, format, password) + }); + } + decode_ecdsa_sec1_key(path, bytes, format, password) } -fn decode_ecdsa_curve( +fn decode_ecdsa_sec1_key( path: &Path, bytes: &[u8], format: PrivateKeyFormat, password: Option<&[u8]>, ) -> Result, KeyMaterialError> { - if pkcs8_container_kind(bytes, format).is_some() { - return decode_pkcs8_signing_key::(path, bytes, format, password); - } - - let pem_der = match format { + // Decode the envelope once; curve selection only borrows the same secret. + let decoded = match format { PrivateKeyFormat::Pem => { let text = std::str::from_utf8(bytes) .map_err(|_| KeyMaterialError::UnsupportedPrivateKey(path.to_owned()))?; @@ -557,10 +561,18 @@ fn decode_ecdsa_curve( return Err(KeyMaterialError::UnsupportedPrivateKey(path.to_owned())); } }; - let der = pem_der.as_ref().map_or(bytes, |der| der.as_slice()); - let key = K::decode_sec1_der(der).ok(); - key.map(|key| Box::new(key) as Box) - .ok_or_else(|| KeyMaterialError::UnsupportedPrivateKey(path.to_owned())) + let der = decoded.as_ref().map_or(bytes, |key| key.der.as_slice()); + macro_rules! try_curve { + ($key:ty) => { + if let Ok(key) = <$key>::decode_sec1_der(der) { + return Ok(Box::new(key)); + } + }; + } + try_curve!(EcdsaP256SigningKey); + try_curve!(EcdsaP384SigningKey); + try_curve!(EcdsaP521SigningKey); + Err(traditional_key_decode_error(decoded.as_ref(), path)) } fn decode_dsa_signing_key( @@ -591,11 +603,11 @@ fn decode_dsa_signing_key( return Err(KeyMaterialError::UnsupportedPrivateKey(path.to_owned())); } }; - let der = pem_der.as_deref().map_or(bytes, Vec::as_slice); - let traditional = TraditionalDsaPrivateKey::from_der(der) - .map_err(|_| KeyMaterialError::UnsupportedPrivateKey(path.to_owned()))?; + let der = pem_der.as_ref().map_or(bytes, |key| key.der.as_slice()); + let decode_error = || traditional_key_decode_error(pem_der.as_ref(), path); + let traditional = TraditionalDsaPrivateKey::from_der(der).map_err(|_| decode_error())?; if traditional.version != 0 { - return Err(KeyMaterialError::UnsupportedPrivateKey(path.to_owned())); + return Err(decode_error()); } let p = BoxedUint::from_be_slice_vartime(traditional.p.as_bytes()); @@ -603,27 +615,23 @@ fn decode_dsa_signing_key( let g = BoxedUint::from_be_slice_vartime(traditional.g.as_bytes()); let y = BoxedUint::from_be_slice_vartime(traditional.y.as_bytes()); let x = BoxedUint::from_be_slice_vartime(traditional.x.as_bytes()); - let components = DsaComponents::from_components(p, q, g) - .map_err(|_| KeyMaterialError::UnsupportedPrivateKey(path.to_owned()))?; + let components = DsaComponents::from_components(p, q, g).map_err(|_| decode_error())?; let params = BoxedMontyParams::new(components.p().clone()); let expected_y = BoxedMontyForm::new((**components.g()).clone(), ¶ms) .pow(&x) .retrieve(); if expected_y != y { - return Err(KeyMaterialError::UnsupportedPrivateKey(path.to_owned())); + return Err(decode_error()); } - let verifying_key = DsaVerifyingKey::from_components(components, y) - .map_err(|_| KeyMaterialError::UnsupportedPrivateKey(path.to_owned()))?; - let key = NativeDsaSigningKey::from_components(verifying_key, x) - .map_err(|_| KeyMaterialError::UnsupportedPrivateKey(path.to_owned()))?; - let normalized = key - .to_pkcs8_der() - .map_err(|_| KeyMaterialError::UnsupportedPrivateKey(path.to_owned()))?; + let verifying_key = + DsaVerifyingKey::from_components(components, y).map_err(|_| decode_error())?; + let key = NativeDsaSigningKey::from_components(verifying_key, x).map_err(|_| decode_error())?; + let normalized = key.to_pkcs8_der().map_err(|_| decode_error())?; DsaSigningKey::from_pkcs8_der(normalized.as_bytes()) .map(|key| Box::new(key) as Box) - .map_err(|_| KeyMaterialError::UnsupportedPrivateKey(path.to_owned())) + .map_err(|_| decode_error()) } fn decode_rsa_signing_key( @@ -658,16 +666,23 @@ 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(|_| { - 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()) - } - }) + RsaPrivateKey::from_pkcs1_der(&der.der) + .map_err(|_| traditional_key_decode_error(Some(&der), path)) +} + +struct TraditionalPemKey { + der: Zeroizing>, + encrypted: bool, +} + +fn traditional_key_decode_error(key: Option<&TraditionalPemKey>, path: &Path) -> KeyMaterialError { + // CBC padding is not authentication. Once an encrypted envelope has been + // recognized, invalid decoded key material must not enable lax fallback. + if key.is_some_and(|key| key.encrypted) { + KeyMaterialError::ProtectedContainer + } else { + KeyMaterialError::UnsupportedPrivateKey(path.to_owned()) + } } fn decode_openssl_traditional_pem( @@ -675,7 +690,7 @@ fn decode_openssl_traditional_pem( expected_tag: &str, password: Option<&[u8]>, path: &Path, -) -> Result>, KeyMaterialError> { +) -> Result { // The header-aware parser accepts surrounding input, so enforce a single // complete block before trusting its OpenSSL encryption metadata. let text = text.trim_matches(|character: char| character.is_ascii_whitespace()); @@ -696,7 +711,10 @@ fn decode_openssl_traditional_pem( let headers = envelope.headers(); if headers.iter().next().is_none() { - return Ok(Zeroizing::new(envelope.contents().to_vec())); + return Ok(TraditionalPemKey { + der: Zeroizing::new(envelope.contents().to_vec()), + encrypted: false, + }); } if headers.iter().count() != 2 || headers.get("Proc-Type") != Some("4,ENCRYPTED") { return Err(KeyMaterialError::UnsupportedPrivateKey(path.to_owned())); @@ -708,7 +726,12 @@ fn decode_openssl_traditional_pem( let iv = decode_hex(encoded_iv) .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) + decrypt_openssl_legacy_pem(cipher, &iv, envelope.contents(), password, path).map(|der| { + TraditionalPemKey { + der, + encrypted: true, + } + }) } fn decode_hex(value: &str) -> Option> { @@ -1134,6 +1157,34 @@ mod tests { )); } + #[test] + fn encrypted_traditional_dsa_and_ec_reject_valid_padding_invalid_der() { + // CBC padding can succeed without authenticating the plaintext. A + // protected envelope containing invalid DER remains terminal for lax search. + for tag in ["DSA PRIVATE KEY", "EC PRIVATE KEY"] { + let text = encrypted_traditional_pem(tag, b"not ASN.1", b"secret"); + let result = if tag == "DSA PRIVATE KEY" { + decode_dsa_signing_key( + Path::new("key.pem"), + text.as_bytes(), + PrivateKeyFormat::Pem, + Some(b"secret"), + ) + } else { + decode_ecdsa_signing_key( + Path::new("key.pem"), + text.as_bytes(), + PrivateKeyFormat::Pem, + Some(b"secret"), + ) + }; + assert!( + matches!(result, Err(KeyMaterialError::ProtectedContainer)), + "{tag}" + ); + } + } + #[test] fn traditional_encrypted_rsa_pem_preserves_password_failure() { // A protected traditional PEM must not look like a missing key to lax selection. diff --git a/tools/xmlsec1/tests/process_contract.rs b/tools/xmlsec1/tests/process_contract.rs index a3577edc..8a5a17d2 100644 --- a/tools/xmlsec1/tests/process_contract.rs +++ b/tools/xmlsec1/tests/process_contract.rs @@ -6332,6 +6332,51 @@ fn lax_rsa_search_skips_candidates_that_conflict_with_recipient_metadata() { ); } +#[test] +fn lax_stored_rsa_encryption_stops_on_policy_denial() { + // A stored weak recipient is a typed policy failure, not a candidate miss; + // a later strong key must not suppress it or produce encrypted 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("plain.bin"); + let output = temp.path().join("encrypted.xml"); + let weak = RsaPrivateKey::new(&mut ChaCha8Rng::from_seed([0x72; 32]), 1024) + .unwrap() + .to_public_key(); + let strong = RsaPublicKey::from_public_key_pem( + &fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem")) + .unwrap(), + ) + .unwrap(); + let mut xml = String::from(""); + for (name, key) in [("weak", weak), ("strong", strong)] { + xml.push_str(&format!("{name}{}{}", base64::engine::general_purpose::STANDARD.encode(key.n().to_be_bytes_trimmed_vartime()), base64::engine::general_purpose::STANDARD.encode(key.e().to_be_bytes_trimmed_vartime()))); + } + xml.push_str(""); + fs::write(&store, xml).unwrap(); + fs::write(&template, r#"missing"#).unwrap(); + fs::write(&plaintext, b"policy denial is terminal").unwrap(); + let result = 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(); + assert!(!result.status.success()); + assert!( + String::from_utf8_lossy(&result.stderr) + .contains("requires RSA keys between 2048 and 8192 bits: got 1024"), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + assert!(!output.exists()); +} + #[test] fn lax_rsa_encryption_skips_keys_rejected_by_policy() { // Lax lookup searches for a usable RSA recipient. A parseable weak key must From 2c22eacae6b598259153ffedd4d01e58091048e2 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 1 Oct 2026 21:43:38 +0300 Subject: [PATCH 4/9] fix(keys): account KDF and resolver work Charge every PBKDF2 output block and bound scrypt alongside retained inventory before password delivery. Resume certificate fallback without replaying inspected sources while preserving terminal and deferred error classes. Clarify generic CMS attribute cardinality with normative references and boundary tests. --- docs/key-management.md | 7 + src/key_manager.rs | 367 +++++++++++++++++++++++++++++-- src/key_manager/pkcs12_import.rs | 14 +- src/xmldsig/keys.rs | 80 +++++-- 4 files changed, 435 insertions(+), 33 deletions(-) diff --git a/docs/key-management.md b/docs/key-management.md index 84e52d3e..8c9af1d4 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -88,6 +88,10 @@ resource-policy error without invoking the callback. 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. +PBKDF2 work includes every output block required by the cipher's key width +(RFC 8018 section 5.2). Scrypt workspaces must fit both the KDF-specific ceiling +and the remaining aggregate allowance alongside retained inventory, key name, +and imported container; these checks precede password callbacks. 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 @@ -108,6 +112,9 @@ BER PrivateKeyInfo framing and constructed private-key OCTET STRINGs are normalized to bounded PKCS#8 DER before storage. CMS EncryptedData accepts unprotected attributes with version 2, while requiring version 0 without them (RFC 5652 section 8); metadata framing is validated before password processing. +The outer CMS attribute collection must be nonempty, but an unknown attribute's +generic `attrValues SET OF` has no minimum cardinality (RFC 5652 sections 5.3 +and 6.1). Attribute-specific requirements are not inferred for unknown OIDs. 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 diff --git a/src/key_manager.rs b/src/key_manager.rs index fa9c6f79..cdc0dd7e 100644 --- a/src/key_manager.rs +++ b/src/key_manager.rs @@ -462,12 +462,13 @@ impl<'a> KeyResolver for InventoryVerificationResolver<'a> { }); // Try only sources preceding the first configured-X.509 use without // inspecting or copying inventory certificates that may never be used. + let mut prefix_error = None; 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( + let outcome = prefix_resolver.resolve_sources_with_candidate_budget( info, algorithm, policy, @@ -478,12 +479,11 @@ impl<'a> KeyResolver for InventoryVerificationResolver<'a> { } 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()), - _ => {} + )?; + if let Some(key) = outcome.key { + return Ok(Some(key)); } + prefix_error = outcome.deferred_error.map(DsigError::from); } let fallback = if configured_x509_index.is_some() { let certificates = self @@ -543,6 +543,30 @@ impl<'a> KeyResolver for InventoryVerificationResolver<'a> { } else { DefaultKeyResolver::new(KeyResolverConfig::default()) }; + if let Some(first_x509) = configured_x509_index + && let Some(info) = selected_info + { + // Resume after the inspected prefix: one budget counts actual + // work, not a replay caused by attaching configured certificates. + let result = fallback + .resolve_sources_with_candidate_budget( + info, + algorithm, + policy, + provider, + &mut inspected_candidates, + if candidate.is_some() { + crate::xmldsig::keys::ResolutionScope::TrustedSuffix(first_x509) + } else { + crate::xmldsig::keys::ResolutionScope::DocumentSuffix(first_x509) + }, + ) + .and_then(crate::xmldsig::keys::SourceResolution::finish); + return match (result, prefix_error) { + (Ok(None), Some(error)) => Err(error), + (result, _) => result, + }; + } if let Some(candidate) = candidate { return fallback.resolve_trusted_material_with_candidate_budget( &candidate.key_info, @@ -1150,7 +1174,8 @@ impl KeyInventory { resources.max_external_resource_bytes, )); } - self.check_material_capacity(named_material_length(&name, bytes.len(), 1)?, resources)?; + let retained_with_input = + 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( @@ -1160,7 +1185,7 @@ impl KeyInventory { 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)?; + enforce_pkcs8_kdf_policy(&encrypted, resources, retained_with_input)?; let password = password.ok_or(KeyStoreError::ProtectedContainer)?; let plain = encrypted .decrypt(password) @@ -1222,9 +1247,10 @@ impl KeyInventory { resources.max_external_resource_bytes, )); } - self.check_material_capacity(named_material_length(&name, bytes.len(), 1)?, resources)?; + let retained_with_input = + 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)?; + enforce_pkcs8_kdf_policy(&encrypted, resources, retained_with_input)?; Some(password().ok_or(KeyStoreError::ProtectedContainer)?) } else { None @@ -2059,6 +2085,7 @@ fn single_pem_block(bytes: &[u8], maximum: usize) -> Result, resources: &ResourcePolicy, + retained_with_input: usize, ) -> Result<(), KeyStoreError> { use pkcs8::pkcs5::{EncryptionScheme, pbes2::Kdf}; // RFC 8018 §6.2 leaves KDF iteration policy to the application. Reject @@ -2072,11 +2099,25 @@ fn enforce_pkcs8_kdf_policy( if kdf.iteration_count == 0 { return Err(KeyStoreError::ProtectedContainer); } - if u64::from(kdf.iteration_count) > resources.max_key_import_kdf_work as u64 { + use pkcs8::pkcs5::pbes2::Pbkdf2Prf; + let hash_len = match kdf.prf { + Pbkdf2Prf::HmacWithSha1 => 20, + Pbkdf2Prf::HmacWithSha224 => 28, + Pbkdf2Prf::HmacWithSha256 => 32, + Pbkdf2Prf::HmacWithSha384 => 48, + Pbkdf2Prf::HmacWithSha512 => 64, + _ => return Err(KeyStoreError::ProtectedContainer), + }; + // RFC 8018 5.2 steps 2-3: every ceil(dkLen/hLen) block runs c + // PRFs. The cipher determines dkLen, not a caller's KDF hint. + // https://www.rfc-editor.org/rfc/rfc8018#section-5.2 + let blocks = params.encryption.key_size().div_ceil(hash_len) as u64; + let work = u64::from(kdf.iteration_count).checked_mul(blocks); + if work.is_none_or(|work| work > 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)), + work, )); } } @@ -2086,6 +2127,7 @@ fn enforce_pkcs8_kdf_policy( u64::from(kdf.block_size), u64::from(kdf.parallelization), resources, + retained_with_input, )?; } _ => return Err(KeyStoreError::ProtectedContainer), @@ -2098,6 +2140,7 @@ fn enforce_scrypt_kdf_limits( r: u64, p: u64, resources: &ResourcePolicy, + retained_with_input: usize, ) -> Result<(), KeyStoreError> { if n == 0 || r == 0 || p == 0 { return Err(KeyStoreError::ProtectedContainer); @@ -2124,6 +2167,16 @@ fn enforce_scrypt_kdf_limits( memory, )); } + // The workspace is live alongside the existing inventory, name and + // imported container. An independent KDF ceiling cannot waive that peak. + let total = memory.and_then(|memory| memory.checked_add(retained_with_input as u64)); + if total.is_none_or(|total| total > resources.max_external_resource_total_bytes as u64) { + return Err(kdf_policy_violation( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_total_bytes, + total, + )); + } Ok(()) } @@ -3600,6 +3653,292 @@ mod tests { .expect("plaintext import ignores password callback"); } + #[test] + fn pkcs8_pbkdf2_output_blocks_are_checked_before_password() { + use der::Encode as _; + // AES-256/SHA-1 needs two PBKDF2 blocks; three rounds cost six PRFs, + // not three. Denial must precede the public password callback. + use pkcs8::pkcs5::{EncryptionScheme, pbes2}; + let encrypted = EncryptedPrivateKeyInfoRef { + encryption_algorithm: EncryptionScheme::Pbes2(pbes2::Parameters { + kdf: pbes2::Kdf::Pbkdf2(pbes2::Pbkdf2Params { + salt: pbes2::Salt::new(b"12345678").expect("salt"), + iteration_count: 3, + key_length: None, + prf: pbes2::Pbkdf2Prf::HmacWithSha1, + }), + encryption: pbes2::EncryptionScheme::Aes256Cbc { iv: [0; 16] }, + }), + encrypted_data: der::asn1::OctetStringRef::new(&[0; 16]).expect("ciphertext"), + }; + let bytes = encrypted.to_der().expect("encrypted envelope"); + let resources = ResourcePolicy { + max_key_import_kdf_work: 5, + ..ResourcePolicy::default() + }; + let calls = std::cell::Cell::new(0); + let result = KeyInventory::default().add_private_der_with_password_callback( + "key".into(), + &bytes, + || { + calls.set(calls.get() + 1); + None + }, + KeyUsages::SIGN, + &resources, + ); + assert_eq!(calls.get(), 0); + assert!(matches!( + result, + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + maximum: 5, + actual: 6, + } + )) + )); + let exact = ResourcePolicy { + max_key_import_kdf_work: 6, + ..resources + }; + assert!(enforce_pkcs8_kdf_policy(&encrypted, &exact, 0).is_ok()); + // Cover each PRF width and CBC key width, including single-block + // cases so the fix cannot unconditionally double iteration charges. + for (prf, hash_len) in [ + (pbes2::Pbkdf2Prf::HmacWithSha1, 20), + (pbes2::Pbkdf2Prf::HmacWithSha224, 28), + (pbes2::Pbkdf2Prf::HmacWithSha256, 32), + (pbes2::Pbkdf2Prf::HmacWithSha384, 48), + (pbes2::Pbkdf2Prf::HmacWithSha512, 64), + ] { + for cipher in [ + pbes2::EncryptionScheme::Aes128Cbc { iv: [0; 16] }, + pbes2::EncryptionScheme::Aes192Cbc { iv: [0; 16] }, + pbes2::EncryptionScheme::Aes256Cbc { iv: [0; 16] }, + ] { + let mut envelope = encrypted.clone(); + let EncryptionScheme::Pbes2(params) = &mut envelope.encryption_algorithm else { + panic!("PBES2 fixture"); + }; + let pbes2::Kdf::Pbkdf2(kdf) = &mut params.kdf else { + panic!("PBKDF2 fixture"); + }; + kdf.prf = prf; + params.encryption = cipher; + let work = 3 * cipher.key_size().div_ceil(hash_len); + let exact = ResourcePolicy { + max_key_import_kdf_work: work, + ..ResourcePolicy::default() + }; + assert!(enforce_pkcs8_kdf_policy(&envelope, &exact, 0).is_ok()); + let tight = ResourcePolicy { + max_key_import_kdf_work: work - 1, + ..exact + }; + assert!(matches!(enforce_pkcs8_kdf_policy(&envelope, &tight, 0), + Err(KeyStoreError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + actual, .. + })) if actual == work)); + } + } + } + + #[test] + fn pkcs8_scrypt_workspace_shares_retained_inventory_budget() { + use der::Encode as _; + // A workspace below its own ceiling must still fit alongside existing + // inventory material; rejection must precede password delivery. + use pkcs8::pkcs5::{EncryptionScheme, pbes2}; + let encrypted = EncryptedPrivateKeyInfoRef { + encryption_algorithm: EncryptionScheme::Pbes2(pbes2::Parameters { + kdf: pbes2::Kdf::Scrypt(pbes2::ScryptParams { + salt: pbes2::Salt::new(b"12345678").expect("salt"), + cost_parameter: 16, + block_size: 1, + parallelization: 1, + key_length: None, + }), + encryption: pbes2::EncryptionScheme::Aes256Cbc { iv: [0; 16] }, + }), + encrypted_data: der::asn1::OctetStringRef::new(&[0; 16]).expect("ciphertext"), + }; + let bytes = encrypted.to_der().expect("envelope"); + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "old".into(), + SymmetricKeyKind::Hmac, + vec![7; 1024], + KeyUsages::SIGN, + &ResourcePolicy::default(), + ) + .expect("retained key"); + let resources = ResourcePolicy { + max_external_resource_total_bytes: inventory.material_bytes + 2304, + max_key_import_kdf_memory_bytes: 4096, + ..ResourcePolicy::default() + }; + let calls = std::cell::Cell::new(0); + let result = inventory.add_private_der_with_password_callback( + "new".into(), + &bytes, + || { + calls.set(calls.get() + 1); + None + }, + KeyUsages::SIGN, + &resources, + ); + assert_eq!(calls.get(), 0); + assert!(matches!( + result, + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + .. + } + )) + )); + assert!(inventory.private_keys().is_empty()); + assert_eq!(inventory.symmetric_keys().len(), 1); + let exact = ResourcePolicy { + max_external_resource_total_bytes: inventory.material_bytes + bytes.len() + 3 + 2304, + ..resources + }; + assert!(matches!( + inventory.add_private_der_with_password_callback( + "new".into(), + &bytes, + || { + calls.set(calls.get() + 1); + None + }, + KeyUsages::SIGN, + &exact, + ), + Err(KeyStoreError::ProtectedContainer) + )); + assert_eq!( + calls.get(), + 1, + "an exactly fitting workspace reaches password delivery" + ); + } + + #[test] + fn configured_x509_fallback_preserves_terminal_prefix_errors() { + // Attaching configured certificates must not turn malformed earlier + // DER key material into a successful certificate fallback. + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + let certificate = single_pem_block( + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-cert.pem"), + resources.max_external_resource_bytes, + ) + .expect("certificate") + .into_contents(); + let subject = crate::xmldsig::parse::parse_x509_certificate(&certificate) + .expect("leaf") + .subject_dn; + inventory + .add_certificate_der(certificate, false, &resources) + .expect("lookup"); + let mut info = KeyInfo { + sources: vec![ + KeyInfoSource::DerEncodedKeyValue(vec![0]), + KeyInfoSource::X509Data(X509DataInfo { + subject_names: vec![subject], + ..X509DataInfo::default() + }), + ], + }; + assert!( + inventory + .verification_resolver() + .resolve(Some(&info), SignatureAlgorithm::RsaSha256,) + .is_err(), + "a terminal prefix error must stop resolution" + ); + // Unsupported EC material for an RSA method is explicitly deferrable; + // a certificate can satisfy it, but a complete miss retains the error. + info.sources[0] = KeyInfoSource::KeyValue(KeyValueInfo::InvalidEcKeyValue); + assert!( + inventory + .verification_resolver() + .resolve(Some(&info), SignatureAlgorithm::RsaSha256,) + .expect("deferred prefix permits certificate fallback") + .is_some() + ); + let KeyInfoSource::X509Data(data) = &mut info.sources[1] else { + panic!("X509 selector"); + }; + data.subject_names = vec!["CN=absent".into()]; + assert!( + inventory + .verification_resolver() + .resolve(Some(&info), SignatureAlgorithm::RsaSha256,) + .is_err(), + "a complete miss retains its deferred error" + ); + } + + #[test] + fn configured_x509_fallback_does_not_reinspect_prefix() { + // One absent name, its document source, and the certificate inspection + // fit exactly. Replaying the source prefix incorrectly exhausts it. + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + let certificate = single_pem_block( + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-cert.pem"), + resources.max_external_resource_bytes, + ) + .expect("certificate") + .into_contents(); + let subject = crate::xmldsig::parse::parse_x509_certificate(&certificate) + .expect("leaf") + .subject_dn; + inventory + .add_certificate_der(certificate, false, &resources) + .expect("lookup"); + let info = KeyInfo { + sources: vec![ + KeyInfoSource::KeyName("absent".into()), + KeyInfoSource::X509Data(X509DataInfo { + subject_names: vec![subject], + ..X509DataInfo::default() + }), + ], + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.resources.max_key_candidates = 3; + assert!( + inventory + .verification_resolver() + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::RsaSha256, + &policy, + crate::provider::default_provider(), + ) + .expect("each source inspected once") + .is_some() + ); + policy.resources.max_key_candidates = 2; + assert!(matches!( + inventory + .verification_resolver() + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::RsaSha256, + &policy, + crate::provider::default_provider(), + ), + Err(DsigError::Policy(_)) + )); + } + #[test] fn scrypt_parallel_buffers_are_checked_before_derivation() { // N*r fits a tiny limit, but p independent B/V/T workspaces do not. @@ -3609,7 +3948,7 @@ mod tests { ..ResourcePolicy::default() }; assert!(matches!( - enforce_scrypt_kdf_limits(2, 1, 1_000, &resources), + enforce_scrypt_kdf_limits(2, 1, 1_000, &resources, 0), Err(KeyStoreError::Policy( crate::policy::PolicyViolation::ResourceLimit { resource: crate::policy::resource_name::KEY_IMPORT_KDF_MEMORY, @@ -3619,7 +3958,7 @@ mod tests { )) )); resources.max_key_import_kdf_memory_bytes = 512_000; - assert!(enforce_scrypt_kdf_limits(2, 1, 1_000, &resources).is_ok()); + assert!(enforce_scrypt_kdf_limits(2, 1, 1_000, &resources, 0).is_ok()); } #[test] diff --git a/src/key_manager/pkcs12_import.rs b/src/key_manager/pkcs12_import.rs index 20672cf8..f2458c1a 100644 --- a/src/key_manager/pkcs12_import.rs +++ b/src/key_manager/pkcs12_import.rs @@ -776,11 +776,13 @@ fn validate_attribute_values(mut bytes: &[u8], depth: usize) -> Result<()> { } fn validate_attributes(mut attributes: Reader<'_>) -> Result<()> { - // RFC 7292 4.2 and RFC 5652 10.2.1 define attributes 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. + // RFC 7292 4.2 and RFC 5652 5.3 define attributes as an OID and + // a SET OF values, with no generic minimum number of values. The + // SIZE (1..MAX) constraint in CMS 6.1 is on UnprotectedAttributes, + // not attrValues: do not reject an empty SET for an unknown attribute. + // Ignoring its meaning does not waive framing; validate borrowed bytes. // https://www.rfc-editor.org/rfc/rfc7292#section-4.2 - // https://www.rfc-editor.org/rfc/rfc5652#section-10.2.1 + // https://www.rfc-editor.org/rfc/rfc5652#section-5.3 while !attributes.0.is_empty() { let mut attribute = Reader(attributes.take(0x30)?.value); attribute.oid()?; @@ -1253,9 +1255,13 @@ mod tests { oid(Oid::new_unwrap("1.2.3.4")), encoded(0x31, &encoded(4, b"value")), ]); + // RFC 5652 5.3 constrains the outer attribute collection, not the + // generic Attribute.attrValues SET OF. Empty unknown values are valid. + let empty_values = sequence(&[oid(Oid::new_unwrap("1.2.3.4")), encoded(0x31, &[])]); for (version, attrs, accepted) in [ (0, None, true), (2, Some(attribute.clone()), true), + (2, Some(empty_values), true), (0, Some(attribute), false), (2, None, false), (2, Some(Vec::new()), false), diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index cc2a4ef8..61b92e4f 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -464,6 +464,24 @@ pub(crate) enum ResolutionScope { Trusted, DocumentPrefix(usize), TrustedPrefix(usize), + DocumentSuffix(usize), + TrustedSuffix(usize), +} + +/// A partial source scan separates a deferred mismatch from terminal errors. +/// Continuations may retain the former, but cannot retry the latter. +pub(crate) struct SourceResolution { + pub(crate) key: Option>, + pub(crate) deferred_error: Option, +} + +impl SourceResolution { + pub(crate) fn finish(self) -> Result>, DsigError> { + if let Some(error) = self.deferred_error { + return Err(error.into()); + } + Ok(self.key) + } } /// Counts candidates actually inspected by one resolver invocation. @@ -588,7 +606,7 @@ impl DefaultKeyResolver { ) } - pub(crate) fn resolve_prefix_with_candidate_budget( + pub(crate) fn resolve_sources_with_candidate_budget( &self, key_info: &KeyInfo, algorithm: SignatureAlgorithm, @@ -596,8 +614,8 @@ impl DefaultKeyResolver { provider: &dyn crate::provider::CryptoProvider, candidate_budget: &mut InspectedKeyCandidateBudget, scope: ResolutionScope, - ) -> Result>, DsigError> { - self.resolve_with_trust( + ) -> Result { + self.resolve_source_range( Some(key_info), algorithm, policy, @@ -1107,16 +1125,41 @@ impl DefaultKeyResolver { candidate_budget: &mut InspectedKeyCandidateBudget, scope: ResolutionScope, ) -> Result>, DsigError> { + self.resolve_source_range( + key_info, + algorithm, + policy, + provider, + candidate_budget, + scope, + )? + .finish() + } + + fn resolve_source_range( + &self, + key_info: Option<&KeyInfo>, + algorithm: SignatureAlgorithm, + policy: &crate::policy::VerificationPolicy, + provider: &dyn crate::provider::CryptoProvider, + candidate_budget: &mut InspectedKeyCandidateBudget, + scope: ResolutionScope, + ) -> Result { let trust = &policy.key_trust; let resources = &policy.resources; trust.validate()?; resources.validate()?; let Some(key_info) = key_info else { - return Ok(None); + return Ok(SourceResolution { + key: None, + deferred_error: None, + }); }; let document_sources = matches!( scope, - ResolutionScope::Document | ResolutionScope::DocumentPrefix(_) + ResolutionScope::Document + | ResolutionScope::DocumentPrefix(_) + | ResolutionScope::DocumentSuffix(_) ); if document_sources { validate_key_info_source_permissions(key_info, policy.key_sources)?; @@ -1125,9 +1168,13 @@ impl DefaultKeyResolver { ResolutionScope::DocumentPrefix(end) | ResolutionScope::TrustedPrefix(end) => end, _ => key_info.sources.len(), }; + let source_start = match scope { + ResolutionScope::DocumentSuffix(start) | ResolutionScope::TrustedSuffix(start) => start, + _ => 0, + }; let mut deferred_key_value_error = None; let mut configured_material_checked = false; - for source in &key_info.sources[..source_end] { + for source in &key_info.sources[source_start..source_end] { if !document_sources && matches!(source, KeyInfoSource::KeyName(_)) { continue; } @@ -1190,17 +1237,20 @@ impl DefaultKeyResolver { } }; if let Some(key) = resolved { - return Ok(Some(Box::new(PolicyBoundVerificationKey { - key, - rsa_minimum_bits: trust.rsa_keys.minimum_modulus_bits, - dsa_minimum_bits: trust.dsa_keys.minimum_modulus_bits, - }))); + return Ok(SourceResolution { + key: Some(Box::new(PolicyBoundVerificationKey { + key, + rsa_minimum_bits: trust.rsa_keys.minimum_modulus_bits, + dsa_minimum_bits: trust.dsa_keys.minimum_modulus_bits, + })), + deferred_error: None, + }); } } - if let Some(error) = deferred_key_value_error { - return Err(error.into()); - } - Ok(None) + Ok(SourceResolution { + key: None, + deferred_error: deferred_key_value_error, + }) } } From 82f89264d32e36c78e3198e46e813b2b407f5c5f Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 1 Oct 2026 22:27:38 +0300 Subject: [PATCH 5/9] fix(keys): preserve import and fallback state --- docs/key-management.md | 6 +- src/key_manager.rs | 203 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 187 insertions(+), 22 deletions(-) diff --git a/docs/key-management.md b/docs/key-management.md index 8c9af1d4..312fb8d1 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -67,6 +67,9 @@ 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. +Configured X.509 fallback resumes after previously inspected sources. If no key +resolves, it retains the first deferred key mismatch in source order; terminal +errors stop resolution immediately rather than becoming fallback candidates. `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. @@ -91,7 +94,8 @@ missing or incorrect passwords remain protected-container errors. PBKDF2 work includes every output block required by the cipher's key width (RFC 8018 section 5.2). Scrypt workspaces must fit both the KDF-specific ceiling and the remaining aggregate allowance alongside retained inventory, key name, -and imported container; these checks precede password callbacks. +and imported container; these checks precede password callbacks. For PEM imports, +the encoded input and decoded DER coexist and both count toward that peak. 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 diff --git a/src/key_manager.rs b/src/key_manager.rs index cdc0dd7e..ecbf0fbf 100644 --- a/src/key_manager.rs +++ b/src/key_manager.rs @@ -483,7 +483,7 @@ impl<'a> KeyResolver for InventoryVerificationResolver<'a> { if let Some(key) = outcome.key { return Ok(Some(key)); } - prefix_error = outcome.deferred_error.map(DsigError::from); + prefix_error = outcome.deferred_error; } let fallback = if configured_x509_index.is_some() { let certificates = self @@ -548,24 +548,26 @@ impl<'a> KeyResolver for InventoryVerificationResolver<'a> { { // Resume after the inspected prefix: one budget counts actual // work, not a replay caused by attaching configured certificates. - let result = fallback - .resolve_sources_with_candidate_budget( - info, - algorithm, - policy, - provider, - &mut inspected_candidates, - if candidate.is_some() { - crate::xmldsig::keys::ResolutionScope::TrustedSuffix(first_x509) - } else { - crate::xmldsig::keys::ResolutionScope::DocumentSuffix(first_x509) - }, - ) - .and_then(crate::xmldsig::keys::SourceResolution::finish); - return match (result, prefix_error) { - (Ok(None), Some(error)) => Err(error), - (result, _) => result, - }; + let mut outcome = fallback.resolve_sources_with_candidate_budget( + info, + algorithm, + policy, + provider, + &mut inspected_candidates, + if candidate.is_some() { + crate::xmldsig::keys::ResolutionScope::TrustedSuffix(first_x509) + } else { + crate::xmldsig::keys::ResolutionScope::DocumentSuffix(first_x509) + }, + )?; + // Terminal suffix failures have already propagated. On a complete + // miss, keep the first deferred error in original source order. + if outcome.key.is_none() + && let Some(error) = prefix_error + { + outcome.deferred_error = Some(error); + } + return outcome.finish(); } if let Some(candidate) = candidate { return fallback.resolve_trusted_material_with_candidate_budget( @@ -1166,6 +1168,18 @@ impl KeyInventory { password: Option<&[u8]>, usages: KeyUsages, resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.add_private_der_inner(name, bytes, password, usages, resources, 0) + } + + fn add_private_der_inner( + &mut self, + name: String, + bytes: &[u8], + password: Option<&[u8]>, + usages: KeyUsages, + resources: &ResourcePolicy, + live_encoded_bytes: usize, ) -> Result<(), KeyStoreError> { self.check_new_name(&name, resources)?; if bytes.len() > resources.max_external_resource_bytes { @@ -1185,7 +1199,17 @@ impl KeyInventory { 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, retained_with_input)?; + // PEM decoding does not end the caller's encoded buffer lifetime. + // Its bytes coexist with DER and the KDF workspace, not replace DER. + let kdf_live_bytes = retained_with_input + .checked_add(live_encoded_bytes) + .ok_or_else(|| { + import_resource_limit( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_total_bytes, + ) + })?; + enforce_pkcs8_kdf_policy(&encrypted, resources, kdf_live_bytes)?; let password = password.ok_or(KeyStoreError::ProtectedContainer)?; let plain = encrypted .decrypt(password) @@ -1310,7 +1334,7 @@ impl KeyInventory { } let previous_total = self.material_bytes; let name_len = name.len(); - self.add_private_der(name, &der, password, usages, resources)?; + self.add_private_der_inner(name, &der, password, usages, resources, bytes.len())?; let retained_len = self.material_bytes - previous_total; self.material_bytes = previous_total + name_len + bytes.len().max(retained_len - name_len); Ok(()) @@ -3745,6 +3769,88 @@ mod tests { } } + #[test] + fn encrypted_pem_workspace_accounts_for_live_encoded_input() { + use der::Encode as _; + use pkcs8::pkcs5::{EncryptionScheme, pbes2}; + // PEM and its decoded DER coexist during derivation. A DER-only + // allowance must not authorize that larger peak or mutate inventory. + let encrypted = EncryptedPrivateKeyInfoRef { + encryption_algorithm: EncryptionScheme::Pbes2(pbes2::Parameters { + kdf: pbes2::Kdf::Scrypt(pbes2::ScryptParams { + salt: pbes2::Salt::new(b"12345678").expect("salt"), + cost_parameter: 16, + block_size: 1, + parallelization: 1, + key_length: None, + }), + encryption: pbes2::EncryptionScheme::Aes256Cbc { iv: [0; 16] }, + }), + encrypted_data: der::asn1::OctetStringRef::new(&[0; 16]).expect("ciphertext"), + }; + let der = encrypted.to_der().expect("envelope"); + let pem = pem::encode(&pem::Pem::new("ENCRYPTED PRIVATE KEY", der.clone())); + let peak = 3 + pem.len() + der.len() + 2304; + let mut inventory = KeyInventory::default(); + let tight = ResourcePolicy { + max_external_resource_total_bytes: peak - 1, + ..ResourcePolicy::default() + }; + assert!(matches!( + inventory.add_private_pem("new".into(), pem.as_bytes(), None, KeyUsages::SIGN, &tight), + Err(KeyStoreError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + actual, .. + })) if actual == peak + )); + let exact = ResourcePolicy { + max_external_resource_total_bytes: peak, + ..tight + }; + assert!(matches!( + inventory.add_private_pem("new".into(), pem.as_bytes(), None, KeyUsages::SIGN, &exact), + Err(KeyStoreError::ProtectedContainer) + )); + assert!(matches!( + inventory.add_private_der("new".into(), &der, None, KeyUsages::SIGN, &tight), + Err(KeyStoreError::ProtectedContainer) + )); + assert!(inventory.private_keys().is_empty()); + assert_eq!(inventory.material_bytes, 0); + // Exercise actual derivation/import as well as preflight: an exactly + // fitting PEM peak succeeds with the correct password, not just framing. + let plain = pem::parse(include_bytes!( + "../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("private key") + .into_contents(); + let EncryptionScheme::Pbes2(params) = &encrypted.encryption_algorithm else { + panic!("PBES2 envelope"); + }; + let ciphertext = params.encrypt(b"correct", &plain).expect("encrypt"); + let real = EncryptedPrivateKeyInfoRef { + encryption_algorithm: encrypted.encryption_algorithm.clone(), + encrypted_data: der::asn1::OctetStringRef::new(&ciphertext).expect("ciphertext"), + } + .to_der() + .expect("envelope"); + let pem = pem::encode(&pem::Pem::new("ENCRYPTED PRIVATE KEY", real.clone())); + let resources = ResourcePolicy { + max_external_resource_total_bytes: 3 + pem.len() + real.len() + 2304, + ..ResourcePolicy::default() + }; + inventory + .add_private_pem( + "new".into(), + pem.as_bytes(), + Some(b"correct"), + KeyUsages::SIGN, + &resources, + ) + .expect("exact PEM peak imports"); + assert_eq!(inventory.private_keys().len(), 1); + } + #[test] fn pkcs8_scrypt_workspace_shares_retained_inventory_budget() { use der::Encode as _; @@ -3827,6 +3933,61 @@ mod tests { ); } + #[test] + fn configured_x509_continuation_retains_first_deferred_error() { + // Splitting at configured X509 must preserve the unsplit diagnostic + // order, while a later terminal failure still stops immediately. + let mut inventory = KeyInventory::default(); + inventory + .add_certificate_der( + single_pem_block( + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-cert.pem"), + ResourcePolicy::default().max_external_resource_bytes, + ) + .expect("certificate") + .into_contents(), + false, + &ResourcePolicy::default(), + ) + .expect("lookup"); + let mut info = KeyInfo { + sources: vec![ + KeyInfoSource::KeyValue(KeyValueInfo::Dsa { + p: None, + q: None, + g: None, + y: vec![1], + }), + KeyInfoSource::X509Data(X509DataInfo { + subject_names: vec!["CN=absent".into()], + ..X509DataInfo::default() + }), + KeyInfoSource::KeyValue(KeyValueInfo::InvalidEcKeyValue), + ], + }; + let unsplit = DefaultKeyResolver::new(KeyResolverConfig::default()) + .resolve(Some(&info), SignatureAlgorithm::RsaSha256) + .err() + .expect("mismatch"); + let split = inventory + .verification_resolver() + .resolve(Some(&info), SignatureAlgorithm::RsaSha256) + .err() + .expect("mismatch"); + assert_eq!(format!("{split:?}"), format!("{unsplit:?}")); + info.sources[2] = KeyInfoSource::DerEncodedKeyValue(vec![0]); + let unsplit = DefaultKeyResolver::new(KeyResolverConfig::default()) + .resolve(Some(&info), SignatureAlgorithm::RsaSha256) + .err() + .expect("terminal"); + let split = inventory + .verification_resolver() + .resolve(Some(&info), SignatureAlgorithm::RsaSha256) + .err() + .expect("terminal"); + assert_eq!(format!("{split:?}"), format!("{unsplit:?}")); + } + #[test] fn configured_x509_fallback_preserves_terminal_prefix_errors() { // Attaching configured certificates must not turn malformed earlier From e3a892b65a216f48776fa8e44c4b64fd8c863ac7 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 1 Oct 2026 23:33:16 +0300 Subject: [PATCH 6/9] fix(keys): bound protected-key decoding Account for live PFX input and PKCS#8 output before decryption or password callbacks, retaining one zeroizing plaintext allocation. Accept bounded constructed BER IVs and preserve typed policy denials for oversized KDF counters. Add boundary regressions and update import documentation. --- docs/key-management.md | 12 +- src/key_manager.rs | 149 ++++++++++++++++-- src/key_manager/pkcs12_import.rs | 252 +++++++++++++++++++++++++++++-- 3 files changed, 389 insertions(+), 24 deletions(-) diff --git a/docs/key-management.md b/docs/key-management.md index 312fb8d1..4d1d706f 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -94,7 +94,9 @@ missing or incorrect passwords remain protected-container errors. PBKDF2 work includes every output block required by the cipher's key width (RFC 8018 section 5.2). Scrypt workspaces must fit both the KDF-specific ceiling and the remaining aggregate allowance alongside retained inventory, key name, -and imported container; these checks precede password callbacks. For PEM imports, +and imported container; these checks precede password callbacks. The ciphertext-sized +PKCS#8 decryption buffer also counts toward the peak, even when decryption fails. +It is decrypted in place and retained without a second plaintext copy. For PEM imports, the encoded input and decoded DER coexist and both count toward that peak. 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. @@ -120,7 +122,13 @@ The outer CMS attribute collection must be nonempty, but an unknown attribute's generic `attrValues SET OF` has no minimum cardinality (RFC 5652 sections 5.3 and 6.1). Attribute-specific requirements are not inferred for unknown OIDs. Temporary import allocations share the aggregate allowance with material already -retained by the inventory, and KDF workspaces are checked before derivation. +retained by the inventory and the still-live caller-owned PFX input; KDF workspaces +are checked before derivation. AES-CBC IVs accept primitive and constructed BER +OCTET STRINGs, including nested and indefinite segmentation, without heap +flattening; their decoded length must still be exactly 16 bytes (X.690 section 8.7). +Canonical positive KDF iteration INTEGERs exceeding the work limit return typed +policy denials even when larger than a machine integer. Malformed INTEGER sign +encodings remain protected-container errors, not policy errors. Named direct AES keys participate only in direct content-key resolution, not in recipient-key unwrapping, so recipient hints cannot duplicate their candidate. diff --git a/src/key_manager.rs b/src/key_manager.rs index ecbf0fbf..e289e43e 100644 --- a/src/key_manager.rs +++ b/src/key_manager.rs @@ -1211,10 +1211,18 @@ impl KeyInventory { })?; enforce_pkcs8_kdf_policy(&encrypted, resources, kdf_live_bytes)?; let password = password.ok_or(KeyStoreError::ProtectedContainer)?; - let plain = encrypted - .decrypt(password) + // Decrypt in the one preflighted, zeroizing output allocation; + // SecretDocument followed by to_vec would retain two plaintext copies. + let mut plain = Zeroizing::new(encrypted.encrypted_data.as_bytes().to_vec()); + let plaintext_len = encrypted + .encryption_algorithm + .decrypt_in_place(password, &mut plain) .map_err(|_| KeyStoreError::ProtectedContainer)?; - Zeroizing::new(plain.as_bytes().to_vec()) + let plaintext_len = plaintext_len.len(); + plain.truncate(plaintext_len); + PrivateKeyInfoRef::try_from(plain.as_slice()) + .map_err(|_| KeyStoreError::ProtectedContainer)?; + plain } else { preflight_rsa_pkcs1_components(bytes)?; let rsa = RsaPrivateKey::from_pkcs1_der(bytes) @@ -1528,14 +1536,14 @@ impl KeyInventory { }, )); } - self.check_material_capacity(named_material_length(name, bytes.len(), 1)?, resources)?; + let retained_with_input = + 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(), + // Borrowing the PFX does not end its lifetime during decryption. + memory_available: resources.max_external_resource_total_bytes - retained_with_input, }) } @@ -2118,6 +2126,19 @@ fn enforce_pkcs8_kdf_policy( let EncryptionScheme::Pbes2(params) = &encrypted.encryption_algorithm else { return Err(KeyStoreError::ProtectedContainer); }; + // PBES2 decryption mutates a ciphertext-sized output buffer while the + // encoded input remains live. Include that capacity before any password + // callback or KDF, including failed padding/DER decoding. + let live_with_output = + retained_with_input.checked_add(encrypted.encrypted_data.as_bytes().len()); + if live_with_output.is_none_or(|total| total > resources.max_external_resource_total_bytes) { + return Err(kdf_policy_violation( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_total_bytes, + live_with_output.map(|total| total as u64), + )); + } + let live_with_output = live_with_output.ok_or(KeyStoreError::ProtectedContainer)?; match ¶ms.kdf { Kdf::Pbkdf2(kdf) => { if kdf.iteration_count == 0 { @@ -2151,7 +2172,7 @@ fn enforce_pkcs8_kdf_policy( u64::from(kdf.block_size), u64::from(kdf.parallelization), resources, - retained_with_input, + live_with_output, )?; } _ => return Err(KeyStoreError::ProtectedContainer), @@ -2760,6 +2781,34 @@ mod tests { ); } + #[test] + fn pkcs12_temporary_budget_excludes_live_input() { + // The borrowed PFX remains live during both prepare and decrypt; + // plaintext and KDF allocations must not reuse its allowance. + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "old".into(), + SymmetricKeyKind::Hmac, + vec![7; 32], + KeyUsages::SIGN, + &resources, + ) + .expect("retained key"); + let input = [0; 128]; + let limits = inventory + .pkcs12_import_limits("new", &input, KeyUsages::SIGN, &resources) + .expect("import allowance"); + assert_eq!( + limits.memory_available, + resources.max_external_resource_total_bytes + - inventory.material_bytes + - 3 + - input.len() + ); + } + #[test] fn pkcs12_input_consumes_aggregate_import_budget() { // Repeated containers must charge encoded bytes, even when decoded material is small. @@ -3769,6 +3818,80 @@ mod tests { } } + #[test] + fn encrypted_pkcs8_plaintext_is_reserved_before_password() { + use der::Encode as _; + use pkcs8::pkcs5::{EncryptionScheme, pbes2}; + // PBKDF2 has no large workspace, but decrypt still allocates a full + // ciphertext-sized buffer, even for a wrong password/malformed key. + let encrypted = EncryptedPrivateKeyInfoRef { + encryption_algorithm: EncryptionScheme::Pbes2(pbes2::Parameters { + kdf: pbes2::Kdf::Pbkdf2(pbes2::Pbkdf2Params { + salt: pbes2::Salt::new(b"12345678").expect("salt"), + iteration_count: 2, + key_length: None, + prf: pbes2::Pbkdf2Prf::HmacWithSha256, + }), + encryption: pbes2::EncryptionScheme::Aes256Cbc { iv: [0; 16] }, + }), + encrypted_data: der::asn1::OctetStringRef::new(&[0; 64]).expect("ciphertext"), + }; + let bytes = encrypted.to_der().expect("envelope"); + let peak = 3 + bytes.len() + 64; + let tight = ResourcePolicy { + max_external_resource_total_bytes: peak - 1, + ..ResourcePolicy::default() + }; + let calls = std::cell::Cell::new(0); + let mut inventory = KeyInventory::default(); + let result = inventory.add_private_der_with_password_callback( + "new".into(), + &bytes, + || { + calls.set(calls.get() + 1); + None + }, + KeyUsages::SIGN, + &tight, + ); + assert_eq!(calls.get(), 0); + assert!( + matches!(result, Err(KeyStoreError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, actual, .. + })) if actual == peak) + ); + assert!(matches!( + inventory.add_private_der( + "new".into(), + &bytes, + Some(b"wrong"), + KeyUsages::SIGN, + &tight + ), + Err(KeyStoreError::Policy(_)) + )); + let exact = ResourcePolicy { + max_external_resource_total_bytes: peak, + ..tight + }; + assert!(matches!( + inventory.add_private_der_with_password_callback( + "new".into(), + &bytes, + || { + calls.set(calls.get() + 1); + None + }, + KeyUsages::SIGN, + &exact + ), + Err(KeyStoreError::ProtectedContainer) + )); + assert_eq!(calls.get(), 1); + assert!(inventory.private_keys().is_empty()); + assert_eq!(inventory.material_bytes, 0); + } + #[test] fn encrypted_pem_workspace_accounts_for_live_encoded_input() { use der::Encode as _; @@ -3790,7 +3913,7 @@ mod tests { }; let der = encrypted.to_der().expect("envelope"); let pem = pem::encode(&pem::Pem::new("ENCRYPTED PRIVATE KEY", der.clone())); - let peak = 3 + pem.len() + der.len() + 2304; + let peak = 3 + pem.len() + der.len() + 16 + 2304; let mut inventory = KeyInventory::default(); let tight = ResourcePolicy { max_external_resource_total_bytes: peak - 1, @@ -3836,7 +3959,7 @@ mod tests { .expect("envelope"); let pem = pem::encode(&pem::Pem::new("ENCRYPTED PRIVATE KEY", real.clone())); let resources = ResourcePolicy { - max_external_resource_total_bytes: 3 + pem.len() + real.len() + 2304, + max_external_resource_total_bytes: 3 + pem.len() + real.len() + ciphertext.len() + 2304, ..ResourcePolicy::default() }; inventory @@ -3910,7 +4033,11 @@ mod tests { assert!(inventory.private_keys().is_empty()); assert_eq!(inventory.symmetric_keys().len(), 1); let exact = ResourcePolicy { - max_external_resource_total_bytes: inventory.material_bytes + bytes.len() + 3 + 2304, + max_external_resource_total_bytes: inventory.material_bytes + + bytes.len() + + 3 + + 16 + + 2304, ..resources }; assert!(matches!( diff --git a/src/key_manager/pkcs12_import.rs b/src/key_manager/pkcs12_import.rs index f2458c1a..2137e179 100644 --- a/src/key_manager/pkcs12_import.rs +++ b/src/key_manager/pkcs12_import.rs @@ -249,7 +249,7 @@ impl<'a> Reader<'a> { fn oid(&mut self) -> Result { Oid::from_bytes(self.take(6)?.value).map_err(|_| KeyStoreError::ProtectedContainer) } - fn integer(&mut self) -> Result { + fn nonnegative_integer(&mut self) -> Result<&'a [u8]> { 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. @@ -260,6 +260,10 @@ impl<'a> Reader<'a> { { return malformed(); } + Ok(bytes) + } + fn integer(&mut self) -> Result { + let bytes = self.nonnegative_integer()?; bytes .iter() .try_fold(0_u32, |n, b| { @@ -268,6 +272,26 @@ impl<'a> Reader<'a> { }) .ok_or(KeyStoreError::ProtectedContainer) } + fn kdf_iterations(&mut self, budget: &Budget<'_>) -> Result { + let bytes = self.nonnegative_integer()?; + let significant = if bytes[0] == 0 { &bytes[1..] } else { bytes }; + let maximum = budget.limits.resources.max_key_import_kdf_work; + // RFC 7292 section 4 uses BER INTEGERs, not machine-width counters. + // A canonical positive value beyond the application's work ceiling + // is a policy denial; malformed sign encoding remains a format error. + // https://www.rfc-editor.org/rfc/rfc7292#section-4 + if significant.len() > 4 { + return Err(PolicyViolation::KdfIterationsOutsideLimit { maximum }.into()); + } + let mut rounds = 0_u32; + for byte in significant { + rounds = rounds * 256 + u32::from(*byte); + } + if rounds == 0 || u64::from(rounds) > maximum as u64 || rounds > i32::MAX as u32 { + return Err(PolicyViolation::KdfIterationsOutsideLimit { maximum }.into()); + } + Ok(rounds) + } fn null_or_absent(&mut self) -> Result<()> { if !self.0.is_empty() && !self.take(5)?.value.is_empty() { return malformed(); @@ -457,7 +481,11 @@ impl<'a> Mac<'a> { 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()? }; + let rounds = if mac.0.is_empty() { + 1 + } else { + mac.kdf_iterations(budget)? + }; mac.finish()?; budget.kdf(rounds, 1, &salt)?; Ok(Self { @@ -519,7 +547,7 @@ struct Encryption<'a> { salt: Bytes<'a>, rounds: u32, hash: Option, - iv: &'a [u8], + iv: [u8; 16], } impl<'a> Encryption<'a> { fn parse(encoded: Tlv<'a>, budget: &mut Budget<'_>) -> Result { @@ -540,7 +568,7 @@ impl<'a> Encryption<'a> { let (value, rest) = tlv(derivation.0, 0)?; salt = octets(value, 4, budget)?; derivation.0 = rest; - rounds = derivation.integer()?; + rounds = derivation.kdf_iterations(budget)?; let length = if derivation.0.first() == Some(&2) { Some(derivation.integer()?) } else { @@ -566,11 +594,29 @@ impl<'a> Encryption<'a> { "unsupported PKCS#12 PBES2 encryption scheme", )); }; - iv = scheme.take(4)?.value; + let (value, rest) = tlv(scheme.0, 0)?; + // RFC 7292 section 4 requires BER, whose OCTET STRINGs may be + // constructed (X.690 8.7.1, 8.7.3). AES IVs are exactly 16 bytes; + // stream segments into fixed cipher state rather than flattening + // attacker-controlled segmentation into a temporary heap buffer. + // https://www.itu.int/rec/T-REC-X.690-202102-I/en + let mut decoded_iv = [0; 16]; + let mut length_so_far = 0; + octet_visit(value, 4, 0, &mut |segment| { + if segment.len() > decoded_iv.len() - length_so_far { + return malformed(); + } + decoded_iv[length_so_far..length_so_far + segment.len()].copy_from_slice(segment); + length_so_far += segment.len(); + Ok(()) + })?; + if length_so_far != decoded_iv.len() { + return malformed(); + } + iv = decoded_iv; + scheme.0 = rest; scheme.finish()?; - if iv.len() != cipher.block() - || length.is_some_and(|length| length as usize != cipher.key_len()) - { + if length.is_some_and(|length| length as usize != cipher.key_len()) { return malformed(); } hash = Some(prf); @@ -588,9 +634,9 @@ impl<'a> Encryption<'a> { let (value, rest) = tlv(params.0, 0)?; salt = octets(value, 4, budget)?; params.0 = rest; - rounds = params.integer()?; + rounds = params.kdf_iterations(budget)?; hash = None; - iv = &[]; + iv = [0; 16]; // 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)?; @@ -627,7 +673,7 @@ impl<'a> Encryption<'a> { &mut key[..self.cipher.key_len()] ) ); - iv[..self.iv.len()].copy_from_slice(self.iv); + iv.copy_from_slice(&self.iv); } else { let bmp = password.bmp(budget)?; budget.legacy_workspace(&self.salt, bmp, 64, self.cipher.key_len())?; @@ -1515,6 +1561,190 @@ mod tests { )); } + #[test] + fn pbes2_ber_iv_forms_preserve_decryption() { + // RFC 7292 section 4 accepts BER; X.690 8.7 permits nested, + // definite or indefinite OCTET STRING segmentation for the IV. + let private = pem::parse(include_bytes!( + "../../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("key") + .into_contents(); + let iv = [7; 16]; + let mut key = [0; 16]; + pbkdf2::pbkdf2_hmac::(b"secret", b"12345678", 2, &mut key); + let mut output = vec![0; private.len() + 16]; + let ciphertext = cbc::Encryptor::::new_from_slices(&key, &iv) + .expect("cipher") + .encrypt_padded_b2b::(&private, &mut output) + .expect("padding") + .to_vec(); + let segments = [encoded(4, &iv[..5]), encoded(4, &iv[5..])].concat(); + for encoded_iv in [ + encoded(4, &iv), + encoded(0x24, &segments), + [vec![0x24, 0x80], segments.clone(), vec![0, 0]].concat(), + encoded(0x24, &encoded(0x24, &segments)), + ] { + let algorithm = sequence(&[ + oid(PBES2), + sequence(&[ + sequence(&[ + oid(PBKDF2), + sequence(&[encoded(4, b"12345678"), integer(2)]), + ]), + sequence(&[oid(pkcs8::pkcs5::pbes2::AES_128_CBC_OID), encoded_iv]), + ]), + ]); + let bytes = pfx(&[data(&sequence(&[bag( + pkcs12::PKCS_12_PKCS8_KEY_BAG_OID, + &sequence(&[algorithm, encoded(4, &ciphertext)]), + )]))]); + let mut inventory = super::super::KeyInventory::default(); + inventory + .add_pkcs12("key".into(), &bytes, "secret", &ResourcePolicy::default()) + .expect("all BER IV forms import"); + assert_eq!(inventory.private_keys()[0].pkcs8_der.as_slice(), private); + } + } + + #[test] + fn pbes2_ber_iv_rejects_invalid_segments_and_lengths() { + // Segmentation changes framing, never AES's required IV length or + // the universal OCTET STRING type of each component. + for iv in [ + encoded(4, &[0; 15]), + encoded(4, &[0; 17]), + encoded(0x24, &encoded(4, &[0; 17])), + encoded(0x24, &encoded(2, &[0; 16])), + [vec![0x24, 0x80], encoded(4, &[0; 16])].concat(), + ] { + let algorithm = sequence(&[ + oid(PBES2), + sequence(&[ + sequence(&[oid(PBKDF2), sequence(&[encoded(4, b"salt"), integer(2)])]), + sequence(&[oid(pkcs8::pkcs5::pbes2::AES_128_CBC_OID), iv]), + ]), + ]); + let limits = limits(64); + let mut budget = Budget::new(&limits); + let result = + tlv(&algorithm, 0).and_then(|(value, _)| Encryption::parse(value, &mut budget)); + assert!(matches!(result, Err(KeyStoreError::ProtectedContainer))); + assert_eq!(budget.memory, 0, "IV framing requires no heap allocation"); + } + } + + #[test] + fn kdf_integer_classification_preserves_ber_errors() { + // Machine-width-independent policy denial must not hide malformed + // negative/redundant/empty INTEGER encodings (X.690 8.3.2). + let limits = Limits { + resources: ResourcePolicy { + max_key_import_kdf_work: 128, + ..ResourcePolicy::default() + }, + ..limits(64) + }; + let budget = Budget::new(&limits); + for value in [&[][..], &[0x80][..], &[0, 1][..]] { + let encoded = encoded(2, value); + assert!(matches!( + Reader(&encoded).kdf_iterations(&budget), + Err(KeyStoreError::ProtectedContainer) + )); + } + let exact = encoded(2, &[0, 128]); + assert_eq!( + Reader(&exact).kdf_iterations(&budget).expect("exact limit"), + 128 + ); + let exceeded = encoded(2, &[0, 129]); + assert!(matches!( + Reader(&exceeded).kdf_iterations(&budget), + Err(KeyStoreError::Policy( + PolicyViolation::KdfIterationsOutsideLimit { maximum: 128 } + )) + )); + } + + #[test] + fn oversized_kdf_integers_are_policy_failures() { + // Positive BER INTEGERs do not become malformed merely because + // they exceed a machine integer; reject work before any password. + for value in [&[1, 0, 0, 0, 0][..], &[1, 0, 0, 0, 0, 0, 0, 0, 0][..]] { + let rounds = encoded(2, value); + let pbes2 = sequence(&[ + oid(PBES2), + sequence(&[ + sequence(&[ + oid(PBKDF2), + sequence(&[encoded(4, b"salt"), rounds.clone()]), + ]), + sequence(&[ + oid(pkcs8::pkcs5::pbes2::AES_128_CBC_OID), + encoded(4, &[0; 16]), + ]), + ]), + ]); + let legacy = sequence(&[ + oid(pkcs12::PKCS_12_PBE_WITH_SHAAND3_KEY_TRIPLE_DES_CBC), + sequence(&[encoded(4, b"salt"), rounds.clone()]), + ]); + for algorithm in [pbes2, legacy] { + let bytes = pfx(&[data(&sequence(&[bag( + pkcs12::PKCS_12_PKCS8_KEY_BAG_OID, + &sequence(&[algorithm, encoded(4, &[0; 16])]), + )]))]); + let limits = limits(64); + assert!(matches!( + prepare(&bytes, &limits), + Err(KeyStoreError::Policy( + PolicyViolation::KdfIterationsOutsideLimit { .. } + )) + )); + let calls = std::cell::Cell::new(0); + let result = super::super::KeyInventory::default() + .add_pkcs12_with_password_callback( + "key".into(), + &bytes, + || { + calls.set(calls.get() + 1); + None + }, + super::super::KeyUsages::SIGN, + &limits.resources, + ); + assert!(matches!( + result, + Err(KeyStoreError::Policy( + PolicyViolation::KdfIterationsOutsideLimit { .. } + )) + )); + assert_eq!( + calls.get(), + 0, + "oversized work is rejected before password delivery" + ); + } + let mac = sequence(&[ + sequence(&[ + sequence(&[oid(Oid::new_unwrap("1.3.14.3.2.26")), encoded(5, &[])]), + encoded(4, &[0; 20]), + ]), + encoded(4, b"salt"), + rounds, + ]); + let bytes = sequence(&[integer(3), data(&sequence(&[])), mac]); + assert!(matches!( + prepare(&bytes, &limits(64)), + Err(KeyStoreError::Policy( + PolicyViolation::KdfIterationsOutsideLimit { .. } + )) + )); + } + } + #[test] fn pbes2_cipher_prf_matrix_accepts_utf8_passwords_without_legacy_kdf() { // RFC 8018 PBES2 does not impose RFC 7292's legacy BMP password From 5e89b3db1762e0d89e22f13197dead5b2e37c9ec Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 2 Oct 2026 01:09:33 +0300 Subject: [PATCH 7/9] fix(keys): enforce decryption and import policy Account password hashing and callback capacity before protected-key derivation. Combine embedded and configured X.509 material in the same preflight allowance. Enforce the operation RSA policy during selection and opaque-provider recovery, including recipient wrappers. --- README.md | 3 + docs/key-management.md | 12 ++ docs/xmlenc.md | 10 +- src/key_manager.rs | 252 +++++++++++++++++++++++++++++++++- src/policy.rs | 54 ++++++-- src/provider.rs | 34 +++++ src/xmldsig/keys.rs | 104 ++++++++++++-- src/xmlenc/decrypt.rs | 79 ++++++++++- tests/provider_contract.rs | 83 ++++++++++- tools/xmlsec1/src/commands.rs | 30 +++- 10 files changed, 625 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 37e2b310..9a094dbc 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,9 @@ fn example() -> Result<(), Box> { } ``` +RSA encryption and decryption default to a 2048-bit minimum. Applications accepting legacy +keys must explicitly select a lower operation-policy minimum; importing a key does not bypass it. + See [XML Encryption](docs/xmlenc.md) for reciprocal decryption, key transport, recipient selection, document replacement, and parser policy. diff --git a/docs/key-management.md b/docs/key-management.md index 4d1d706f..2182f78f 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -70,6 +70,13 @@ and document CRLs, with their combined resource budget checked before copying. Configured X.509 fallback resumes after previously inspected sources. If no key resolves, it retains the first deferred key mismatch in source order; terminal errors stop resolution immediately rather than becoming fallback candidates. +Embedded certificates and CRLs share one aggregate byte allowance with the +configured certificates and enabled CRLs before chain parsing or assembly. +RSA decryption selection checks borrowed public components before bigint +decoding. `DecryptionPolicy::rsa_keys` defaults to a 2048-bit minimum; the same +snapshot is enforced again before provider recovery, including opaque keys. +Applications accepting legacy input must explicitly lower this minimum; import +permission and a resolver selected under a weaker policy do not weaken a later operation. `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. @@ -98,6 +105,11 @@ and imported container; these checks precede password callbacks. The ciphertext- PKCS#8 decryption buffer also counts toward the peak, even when decryption fails. It is decrypted in place and retained without a second plaintext copy. For PEM imports, the encoded input and decoded DER coexist and both count toward that peak. +PKCS#8 passwords count toward per-resource and aggregate live-byte limits before +derivation; callback buffers are charged by capacity, not just length. Work includes +one unit per 64 password bytes per HMAC initialization (two passes for scrypt) +in addition to the KDF's derivation work. Plaintext +imports still ignore unused passwords. These limits are product policy, not format syntax. 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 diff --git a/docs/xmlenc.md b/docs/xmlenc.md index dfb8ad65..1b00be84 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -68,7 +68,10 @@ the child for every non-default MGF. the RSA convenience constructor wraps a RustCrypto key into the same contract. The handle exposes only normalized public modulus/exponent metadata required by encryption policy and framing checks. On decryption, `PrivateKeyDecryptor::provider_key` accepts an opaque `KeyRecoveryKey`; private key -material never enters XML orchestration. Capability checks receive complete OAEP digest, MGF, and +material never enters XML orchestration. Exact modulus bit length and public exponent +metadata enforce `DecryptionPolicy::rsa_keys` before recovery, including agreement +between the mathematical modulus width and ciphertext width, without copying the modulus. +Capability checks receive complete OAEP digest, MGF, and label parameters. Key transport and key recovery are independent capabilities, so a private-key provider can advertise recovery without public-key wrapping support. An unsupported provider fails without invoking the key or falling back. @@ -77,6 +80,11 @@ the existing `validate_rsa_recipient_key` remains the RustCrypto convenience for `EncryptionPolicy::rsa_keys` validates every recipient modulus and exponent before provider dispatch. New output defaults to 2048-8192-bit RSA keys; callers can explicitly tighten or relax the minimum for a deployment profile, but cannot exceed the implementation ceiling. +Decryption uses the same default range through `DecryptionPolicy::rsa_keys`. +A caller can explicitly lower its minimum for legacy input; this is application +security policy, not an XMLEnc validity constraint. RSA resolver wrappers must forward +the operation snapshot through `resolve_key_candidates_with_policy`; the standalone +`resolve_key` API uses the default policy. Encryption preflight also applies the operation-wide `ResourcePolicy::max_key_candidates` limit before inspecting or dispatching any configured key: a direct content key consumes one candidate, while recipient mode consumes one candidate per independently wrapped recipient. The separate diff --git a/src/key_manager.rs b/src/key_manager.rs index e289e43e..90af96e2 100644 --- a/src/key_manager.rs +++ b/src/key_manager.rs @@ -950,6 +950,17 @@ impl KeyInventory { )); } check_selected_material_size(entry.pkcs8_der.len(), &policy.resources)?; + // Borrow public PKCS#1 components before bigint decoding: import + // permission is not an exemption from the decryption snapshot. + let info = PrivateKeyInfoRef::try_from(entry.pkcs8_der.as_slice()) + .map_err(|_| KeyStoreError::Selection("incompatible RSA decryption key"))?; + let components = rsa::pkcs1::RsaPrivateKey::from_der(info.private_key.as_bytes()) + .map_err(|_| KeyStoreError::Selection("incompatible RSA decryption key"))?; + policy.rsa_keys.validate_components( + "decryption", + components.modulus.as_bytes(), + components.public_exponent.as_bytes(), + )?; 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))) @@ -1211,6 +1222,7 @@ impl KeyInventory { })?; enforce_pkcs8_kdf_policy(&encrypted, resources, kdf_live_bytes)?; let password = password.ok_or(KeyStoreError::ProtectedContainer)?; + enforce_pkcs8_password_policy(&encrypted, password, resources, kdf_live_bytes)?; // Decrypt in the one preflighted, zeroizing output allocation; // SecretDocument followed by to_vec would retain two plaintext copies. let mut plain = Zeroizing::new(encrypted.encrypted_data.as_bytes().to_vec()); @@ -1281,12 +1293,38 @@ impl KeyInventory { } let retained_with_input = 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, retained_with_input)?; + let encrypted = EncryptedPrivateKeyInfoRef::try_from(bytes).ok(); + let secret = if let Some(encrypted) = &encrypted { + enforce_pkcs8_kdf_policy(encrypted, resources, retained_with_input)?; Some(password().ok_or(KeyStoreError::ProtectedContainer)?) } else { None }; + if let Some(secret) = &secret { + // The callback owns capacity, not only initialized password bytes. + self.check_material_capacity( + named_material_length( + &name, + bytes.len().checked_add(secret.capacity()).ok_or_else(|| { + import_resource_limit( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_total_bytes, + ) + })?, + 1, + )?, + resources, + )?; + let encrypted = encrypted + .as_ref() + .ok_or(KeyStoreError::ProtectedContainer)?; + enforce_pkcs8_password_policy( + encrypted, + secret, + resources, + retained_with_input + secret.capacity() - secret.len(), + )?; + } self.add_private_der( name, bytes, @@ -2180,6 +2218,60 @@ fn enforce_pkcs8_kdf_policy( Ok(()) } +fn enforce_pkcs8_password_policy( + encrypted: &EncryptedPrivateKeyInfoRef<'_>, + password: &[u8], + resources: &ResourcePolicy, + live_bytes: usize, +) -> Result<(), KeyStoreError> { + if password.len() > resources.max_external_resource_bytes { + return Err(kdf_policy_violation( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_bytes, + Some(password.len() as u64), + )); + } + let live = live_bytes.checked_add(password.len()).ok_or_else(|| { + import_resource_limit( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_total_bytes, + ) + })?; + // Product work accounting: one unit per 64 input bytes conservatively + // covers password hashing for the supported SHA PRFs, in addition to rounds. + let hashing_passes = match &encrypted.encryption_algorithm { + pkcs8::pkcs5::EncryptionScheme::Pbes2(params) + if matches!(¶ms.kdf, pkcs8::pkcs5::pbes2::Kdf::Scrypt(_)) => + { + 2 + } + _ => 1, + }; + // Scrypt initializes password-keyed HMAC both before and after ROMix. + let password_work = password.len().div_ceil(64) * hashing_passes; + if password_work > resources.max_key_import_kdf_work { + return Err(kdf_policy_violation( + crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + resources.max_key_import_kdf_work, + Some(password_work as u64), + )); + } + let mut remaining = resources.clone(); + remaining.max_key_import_kdf_work -= password_work; + enforce_pkcs8_kdf_policy(encrypted, &remaining, live).map_err(|error| match error { + KeyStoreError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + actual, + .. + }) => kdf_policy_violation( + crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + resources.max_key_import_kdf_work, + Some(actual.saturating_add(password_work) as u64), + ), + error => error, + }) +} + fn enforce_scrypt_kdf_limits( n: u64, r: u64, @@ -3818,6 +3910,87 @@ mod tests { } } + #[test] + fn protected_password_consumes_live_byte_and_work_budgets() { + use der::Encode as _; + use pkcs8::pkcs5::{EncryptionScheme, pbes2}; + // Password hashing and retained callback capacity must be denied before + // failed padding can hide the resource-policy failure. + let envelope = EncryptedPrivateKeyInfoRef { + encryption_algorithm: EncryptionScheme::Pbes2(pbes2::Parameters { + kdf: pbes2::Kdf::Pbkdf2(pbes2::Pbkdf2Params { + salt: pbes2::Salt::new(b"12345678").expect("salt"), + iteration_count: 2, + key_length: None, + prf: pbes2::Pbkdf2Prf::HmacWithSha256, + }), + encryption: pbes2::EncryptionScheme::Aes256Cbc { iv: [0; 16] }, + }), + encrypted_data: der::asn1::OctetStringRef::new(&[0; 64]).expect("ciphertext"), + } + .to_der() + .expect("envelope"); + let peak = 3 + envelope.len() + 64; + for resources in [ + ResourcePolicy { + max_external_resource_bytes: 255, + ..ResourcePolicy::default() + }, + ResourcePolicy { + max_external_resource_total_bytes: peak + 255, + ..ResourcePolicy::default() + }, + ResourcePolicy { + max_key_import_kdf_work: 5, + ..ResourcePolicy::default() + }, + ] { + let mut inventory = KeyInventory::default(); + assert!(matches!( + inventory.add_private_der( + "new".into(), + &envelope, + Some(&[0; 256]), + KeyUsages::SIGN, + &resources + ), + Err(KeyStoreError::Policy(_)) + )); + assert!(inventory.private_keys().is_empty()); + } + let resources = ResourcePolicy { + max_external_resource_total_bytes: peak + 255, + ..ResourcePolicy::default() + }; + let mut secret = Vec::with_capacity(256); + secret.push(0); + assert!(matches!( + KeyInventory::default().add_private_der_with_password_callback( + "new".into(), + &envelope, + || Some(Zeroizing::new(secret)), + KeyUsages::SIGN, + &resources + ), + Err(KeyStoreError::Policy(_)) + )); + let exact = ResourcePolicy { + max_external_resource_total_bytes: peak + 256, + max_key_import_kdf_work: 6, + ..ResourcePolicy::default() + }; + assert!(matches!( + KeyInventory::default().add_private_der( + "new".into(), + &envelope, + Some(&[0; 256]), + KeyUsages::SIGN, + &exact + ), + Err(KeyStoreError::ProtectedContainer) + )); + } + #[test] fn encrypted_pkcs8_plaintext_is_reserved_before_password() { use der::Encode as _; @@ -3959,7 +4132,12 @@ mod tests { .expect("envelope"); let pem = pem::encode(&pem::Pem::new("ENCRYPTED PRIVATE KEY", real.clone())); let resources = ResourcePolicy { - max_external_resource_total_bytes: 3 + pem.len() + real.len() + ciphertext.len() + 2304, + max_external_resource_total_bytes: 3 + + pem.len() + + real.len() + + ciphertext.len() + + 2304 + + b"correct".len(), ..ResourcePolicy::default() }; inventory @@ -5434,6 +5612,74 @@ mod tests { ); } + #[cfg(feature = "xmlenc")] + #[test] + fn selected_weak_rsa_decryptor_obeys_default_policy() { + // Importing a legacy key grants no exemption from operation minima. + let mut inventory = KeyInventory::default(); + let weak = RsaPrivateKey::new(&mut ChaCha8Rng::seed_from_u64(0xA11C_E502), 1024) + .expect("legacy key generation") + .to_pkcs8_der() + .expect("legacy key encoding"); + inventory + .add_private_der( + "weak".into(), + weak.as_bytes(), + None, + KeyUsages::DECRYPT, + &ResourcePolicy::default(), + ) + .expect("legacy import"); + let error = inventory + .decryption_resolver("weak", &crate::policy::DecryptionPolicy::default()) + .err() + .expect("weak key is rejected"); + assert!( + matches!( + error, + KeyStoreError::Policy(crate::policy::PolicyViolation::KeySize { + operation: "decryption", + .. + }) + ), + "{error:?}" + ); + // A caller can deliberately authorize legacy input, but the snapshot + // must survive inventory selection and the complete recovery pipeline. + let key = RsaPrivateKey::from_pkcs8_der(weak.as_bytes()).expect("legacy decode"); + let mut encryption = crate::policy::EncryptionPolicy::default(); + encryption.rsa_keys.minimum_modulus_bits = 1024; + let encrypted = crate::xmlenc::EncryptedDataBuilder::new( + crate::xmlenc::DataEncryptionAlgorithm::Aes128Gcm, + ) + .policy(encryption) + .recipient_rsa_oaep(key.to_public_key()) + .encrypt_binary(b"legacy consent") + .expect("explicit legacy encryption"); + let mut decryption = crate::policy::DecryptionPolicy::default(); + decryption.rsa_keys.minimum_modulus_bits = 1024; + let resolver = inventory + .decryption_resolver("weak", &decryption) + .expect("explicit legacy selection"); + assert_eq!( + crate::xmlenc::DecryptContext::new(resolver.as_ref()) + .policy(decryption) + .decrypt(&encrypted.encrypted_data_xml) + .expect("explicit legacy recovery"), + crate::xmlenc::DecryptedContent::Bytes(b"legacy consent".to_vec()) + ); + assert!(matches!( + crate::xmlenc::DecryptContext::new(resolver.as_ref()) + .decrypt(&encrypted.encrypted_data_xml), + Err(crate::xmlenc::XmlEncError::Policy( + crate::policy::PolicyViolation::KeySize { + actual_bits: 1024, + .. + } + )) + )); + } + #[cfg(feature = "xmlenc")] #[test] fn selected_rsa_recipient_obeys_operation_modulus_policy() { diff --git a/src/policy.rs b/src/policy.rs index 65e3fe37..32ff0661 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -239,11 +239,11 @@ impl HmacPolicy { } } -/// RSA strength and structural requirements for outbound cryptographic operations. +/// RSA strength and structural requirements for cryptographic operations. #[cfg(any(feature = "xmldsig", feature = "xmlenc"))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct RsaKeyPolicy { - /// Minimum mathematical RSA modulus bit length accepted for new output. + /// Minimum mathematical RSA modulus bit length accepted by the operation. pub minimum_modulus_bits: usize, } @@ -341,6 +341,30 @@ impl RsaKeyPolicy { key_type: "RSA", reason: "modulus width overflows", })?; + let exponent = if exponent.is_empty() || exponent.len() > 8 { + None + } else { + let mut bytes = [0_u8; 8]; + bytes[8 - exponent.len()..].copy_from_slice(exponent); + Some(u64::from_be_bytes(bytes)) + }; + self.validate_public_metadata(operation, modulus_bits, exponent) + } + + pub(crate) fn validate_public_metadata( + &self, + operation: &'static str, + modulus_bits: usize, + exponent: Option, + ) -> Result { + self.validate()?; + if modulus_bits == 0 { + return Err(PolicyViolation::InvalidKeyMaterial { + operation, + key_type: "RSA", + reason: "modulus is zero", + }); + } if !(self.minimum_modulus_bits..=crate::hard_limits::RSA_MODULUS_BIT_CEILING) .contains(&modulus_bits) { @@ -352,16 +376,11 @@ impl RsaKeyPolicy { actual_bits: modulus_bits, }); } - if exponent.is_empty() || exponent.len() > 8 { - return Err(PolicyViolation::InvalidKeyMaterial { - operation, - key_type: "RSA", - reason: "public exponent has invalid encoding", - }); - } - let mut exponent_bytes = [0_u8; 8]; - exponent_bytes[8 - exponent.len()..].copy_from_slice(exponent); - let exponent = u64::from_be_bytes(exponent_bytes); + let exponent = exponent.ok_or(PolicyViolation::InvalidKeyMaterial { + operation, + key_type: "RSA", + reason: "public exponent has invalid encoding", + })?; if !(3..=((1_u64 << 33) - 1)).contains(&exponent) || exponent % 2 == 0 { return Err(PolicyViolation::InvalidKeyMaterial { operation, @@ -369,7 +388,7 @@ impl RsaKeyPolicy { reason: "public exponent is outside the supported odd range", }); } - Ok(modulus.len()) + Ok(modulus_bits.div_ceil(8)) } } @@ -428,7 +447,8 @@ 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. + /// Maximum aggregate PBKDF2/PKCS#12 hash rounds or conservative scrypt work + /// during key import, including PKCS#8 password-hashing work units. 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, @@ -1208,6 +1228,9 @@ pub struct DecryptionPolicy { pub key_wrap_algorithms: Option>, /// Allowed OAEP digest algorithms accepted on input. pub oaep_digests: Option>, + /// RSA requirements enforced before OAEP recovery; defaults to 2048 bits. + /// Legacy input requires an explicit caller-selected lower minimum. + pub rsa_keys: RsaKeyPolicy, /// XML parser rules. pub xml: XmlInputPolicy, /// Resource ceilings. @@ -1218,7 +1241,8 @@ pub struct DecryptionPolicy { impl DecryptionPolicy { /// Validate the complete snapshot before inbound decryption work begins. pub fn validate(&self) -> Result<(), PolicyViolation> { - self.resources.validate() + self.resources.validate()?; + self.rsa_keys.validate() } } diff --git a/src/provider.rs b/src/provider.rs index 4f9d68a0..8733a0d9 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -352,6 +352,14 @@ pub trait KeyTransportKey: Send + Sync { /// public metadata required to reject malformed RSA inputs before dispatch. #[cfg(feature = "xmlenc")] pub trait KeyRecoveryKey: Send + Sync { + /// Exact mathematical bit length of the recovery key's public modulus, + /// excluding leading zero padding. Not the rounded ciphertext width. + fn rsa_modulus_bits(&self) -> usize; + + /// Public exponent of that same key, or `None` if wider than 64 bits. + /// Opaque providers expose public metadata without copying private material. + fn rsa_public_exponent(&self) -> Option; + /// Exact RSA ciphertext width in bytes for the key used by /// [`Self::recover_with_provider`]. fn ciphertext_len(&self) -> usize; @@ -633,6 +641,12 @@ impl From for RustCryptoRsaPrivateKey { #[cfg(feature = "xmlenc")] impl KeyRecoveryKey for RustCryptoRsaPrivateKey { + fn rsa_modulus_bits(&self) -> usize { + KeyRecoveryKey::rsa_modulus_bits(&self.key) + } + fn rsa_public_exponent(&self) -> Option { + KeyRecoveryKey::rsa_public_exponent(&self.key) + } fn ciphertext_len(&self) -> usize { self.ciphertext_len } @@ -649,6 +663,26 @@ impl KeyRecoveryKey for RustCryptoRsaPrivateKey { #[cfg(feature = "xmlenc")] impl KeyRecoveryKey for rsa::RsaPrivateKey { + fn rsa_modulus_bits(&self) -> usize { + use rsa::traits::PublicKeyParts as _; + self.n().bits_vartime() as usize + } + fn rsa_public_exponent(&self) -> Option { + use rsa::traits::PublicKeyParts as _; + if self.e().bits_vartime() > 64 { + return None; + } + let mut exponent = 0_u64; + let word_bits = crypto_bigint::Word::BITS as usize; + for (index, word) in self.e().as_words().iter().take(64 / word_bits).enumerate() { + #[cfg(target_pointer_width = "64")] + let word = *word; + #[cfg(target_pointer_width = "32")] + let word = u64::from(*word); + exponent |= word << (index * word_bits); + } + Some(exponent) + } fn ciphertext_len(&self) -> usize { use rsa::traits::PublicKeyParts as _; self.size() diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 61b92e4f..7d1616f0 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -638,11 +638,13 @@ impl DefaultKeyResolver { fn check_configured_x509_material( &self, + info: &X509DataInfo, resources: &crate::policy::ResourcePolicy, trust: &crate::policy::KeyTrustPolicy, budget: &mut InspectedKeyCandidateBudget, + charge_crls: bool, ) -> Result<(), DsigError> { - if trust.check_crls && trust.verify_x509_chains { + if charge_crls && trust.check_crls && trust.verify_x509_chains { budget.charge_many(self.config.crls.len())?; } let certificates = self @@ -656,7 +658,15 @@ impl DefaultKeyResolver { .iter() .filter(|_| trust.check_crls && trust.verify_x509_chains); let mut total = 0_usize; - for material in certificates.chain(crls) { + // Embedded and configured bytes coexist during chain assembly; this + // combined preflight precedes certificate parsing and cloning. + for material in info + .certificates + .iter() + .chain(&info.crls) + .chain(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, @@ -1180,14 +1190,18 @@ impl DefaultKeyResolver { } let resolved = match source { KeyInfoSource::X509Data(info) => { - 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)?; + if if info.certificate_chain.is_empty() { + x509_data_has_lookup_identifiers(info) + } else { + trust.verify_x509_chains + } { + self.check_configured_x509_material( + info, + resources, + trust, + candidate_budget, + !configured_material_checked, + )?; configured_material_checked = true; } self.resolve_x509(info, algorithm, trust, provider, candidate_budget)? @@ -3894,6 +3908,76 @@ mod tests { )); } + #[test] + fn embedded_and_configured_x509_share_one_byte_budget() { + // Both halves fit separately; their combined live material must fail + // before attempting to parse the deliberately invalid certificate DER. + let mut info = KeyInfo { + sources: vec![KeyInfoSource::X509Data(X509DataInfo { + certificates: vec![vec![0; 8]], + certificate_chain: vec![0], + crls: vec![vec![0; 2]], + ..X509DataInfo::default() + })], + }; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + crls: vec![vec![0; 8]], + ..KeyResolverConfig::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_total_bytes = 17; + let error = resolver + .resolve_with_policy(Some(&info), SignatureAlgorithm::RsaSha256, &policy) + .err() + .expect("combined material must be rejected"); + assert!( + matches!( + error, + DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + actual: 18, + .. + }) + ), + "{error:?}" + ); + policy.resources.max_external_resource_total_bytes = 18; + let error = resolver + .resolve_with_policy(Some(&info), SignatureAlgorithm::RsaSha256, &policy) + .err() + .expect("exact byte allowance reaches DER parsing"); + assert!( + matches!( + error, + DsigError::KeyResolution(KeyResolutionError::InvalidCertificate) + ), + "{error:?}" + ); + // A prior lookup source may charge configured CRLs once, but cannot + // suppress the combined-byte preflight of a later embedded source. + info.sources.insert( + 0, + KeyInfoSource::X509Data(X509DataInfo { + subject_names: vec!["CN=absent".into()], + ..X509DataInfo::default() + }), + ); + policy.resources.max_external_resource_total_bytes = 17; + policy.resources.max_key_candidates = 2; + assert!(matches!( + resolver.resolve_with_policy(Some(&info), SignatureAlgorithm::RsaSha256, &policy), + Err(DsigError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + actual: 18, + .. + } + )) + )); + } + #[test] fn direct_certificate_does_not_charge_unused_configured_store() { // A direct embedded certificate bypasses configured lookup material diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index 3e5d2b89..3c0bfae8 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -85,6 +85,20 @@ impl KeyCandidateBudget { /// Supplies a content-encryption key for parsed XMLEnc data. pub trait DecryptionKeyResolver { + /// Resolve under the operation's immutable snapshot. RSA resolvers enforce + /// `rsa_keys` before provider recovery; wrappers must forward this snapshot. + fn resolve_key_candidates_with_policy( + &self, + provider: &dyn crate::provider::CryptoProvider, + algorithm: DataEncryptionAlgorithm, + encrypted_key: Option<&EncryptedKey>, + policy: &crate::policy::DecryptionPolicy, + budget: &mut KeyCandidateBudget, + ) -> Result>, XmlEncError> { + policy.validate()?; + self.resolve_key_candidates(provider, algorithm, encrypted_key, budget) + } + /// Resolve the symmetric key for `algorithm`, optionally unwrapping `encrypted_key`. fn resolve_key( &self, @@ -572,13 +586,60 @@ impl PrivateKeyDecryptor { } impl DecryptionKeyResolver for PrivateKeyDecryptor { + fn resolve_key_candidates_with_policy( + &self, + provider: &dyn crate::provider::CryptoProvider, + algorithm: DataEncryptionAlgorithm, + encrypted_key: Option<&EncryptedKey>, + policy: &crate::policy::DecryptionPolicy, + budget: &mut KeyCandidateBudget, + ) -> Result>, XmlEncError> { + policy.validate()?; + budget.consume(1)?; + self.resolve_key_with_policy(provider, algorithm, encrypted_key, policy) + .map(|key| vec![key]) + } + fn resolve_key( &self, provider: &dyn crate::provider::CryptoProvider, algorithm: DataEncryptionAlgorithm, encrypted_key: Option<&EncryptedKey>, ) -> Result, XmlEncError> { + self.resolve_key_with_policy( + provider, + algorithm, + encrypted_key, + &crate::policy::DecryptionPolicy::default(), + ) + } +} + +impl PrivateKeyDecryptor { + /// Recover one session key using the exact operation policy. This avoids + /// a temporary candidate collection when composing ordered RSA key rings. + pub fn resolve_key_with_policy( + &self, + provider: &dyn crate::provider::CryptoProvider, + algorithm: DataEncryptionAlgorithm, + encrypted_key: Option<&EncryptedKey>, + policy: &crate::policy::DecryptionPolicy, + ) -> Result, XmlEncError> { + policy.validate()?; let encrypted_key = encrypted_key.ok_or(XmlEncError::KeyNotFound)?; + let width = policy.rsa_keys.validate_public_metadata( + "decryption", + self.key.rsa_modulus_bits(), + self.key.rsa_public_exponent(), + )?; + if width != self.key.ciphertext_len() { + return Err(crate::policy::PolicyViolation::InvalidKeyMaterial { + operation: "decryption", + key_type: "RSA", + reason: "ciphertext width disagrees with modulus", + } + .into()); + } encrypted_key.encryption_method.validate_structure()?; let wrapped = STANDARD .decode(&encrypted_key.cipher_data.value) @@ -886,7 +947,7 @@ fn resolve_content_key_candidates( ) -> Result>, XmlEncError> { let mut last_error = None; let mut candidates = - match resolve_candidates_with_budget(resolver, provider, algorithm, None, budget) { + match resolve_candidates_with_budget(resolver, provider, algorithm, None, policy, budget) { Ok(keys) => keys, Err(error) => { record_candidate_source_error_or_fail_operation(error, &mut last_error)?; @@ -906,6 +967,7 @@ fn resolve_content_key_candidates( provider, algorithm, Some(encrypted_key), + policy, budget, ) { Ok(keys) => candidates.extend(keys), @@ -938,10 +1000,17 @@ fn resolve_candidates_with_budget( provider: &dyn crate::provider::CryptoProvider, algorithm: DataEncryptionAlgorithm, encrypted_key: Option<&EncryptedKey>, + policy: &crate::policy::DecryptionPolicy, budget: &mut KeyCandidateBudget, ) -> Result>, XmlEncError> { let remaining_before = budget.remaining(); - let keys = resolver.resolve_key_candidates(provider, algorithm, encrypted_key, budget)?; + let keys = resolver.resolve_key_candidates_with_policy( + provider, + algorithm, + encrypted_key, + policy, + budget, + )?; budget.account_returned_candidates(remaining_before, keys.len())?; Ok(keys) } @@ -1662,6 +1731,12 @@ mod tests { struct OpaqueRecoveryKey; impl crate::provider::KeyRecoveryKey for OpaqueRecoveryKey { + fn rsa_modulus_bits(&self) -> usize { + 2048 + } + fn rsa_public_exponent(&self) -> Option { + Some(65537) + } fn ciphertext_len(&self) -> usize { 256 } diff --git a/tests/provider_contract.rs b/tests/provider_contract.rs index a3fb74cb..dcf601df 100644 --- a/tests/provider_contract.rs +++ b/tests/provider_contract.rs @@ -38,6 +38,12 @@ impl KeyTransportKey for ExternalPublicKey { struct ExternalPrivateKey; impl KeyRecoveryKey for ExternalPrivateKey { + fn rsa_modulus_bits(&self) -> usize { + 2048 + } + fn rsa_public_exponent(&self) -> Option { + Some(65537) + } fn ciphertext_len(&self) -> usize { 256 } @@ -195,7 +201,7 @@ fn opaque_provider_keys_cross_the_public_encrypt_and_decrypt_pipelines() { .expect("external transport provider must produce EncryptedData"); assert!(encrypted.encrypted_data_xml.contains("rsa-oaep")); - // The reciprocal public resolver exposes only RSA ciphertext width. The + // The reciprocal public resolver exposes RSA public metadata and width. The // custom provider recovers the content key and decrypts without accessing // private key material through RustCrypto. let xml = external_encrypted_xml(); @@ -210,6 +216,81 @@ fn opaque_provider_keys_cross_the_public_encrypt_and_decrypt_pipelines() { ); } +#[test] +fn recovery_key_policy_precedes_opaque_provider_dispatch() { + // A structurally valid ciphertext is not permission to recover with a key + // below the operation minimum, even when a custom provider supports it. + let resolver = PrivateKeyDecryptor::provider_key(Arc::new(ExternalPrivateKey)); + let mut policy = xml_sec::policy::DecryptionPolicy::default(); + policy.rsa_keys.minimum_modulus_bits = 4096; + assert!(matches!( + DecryptContext::new(&resolver) + .policy(policy) + .provider(&ExternalProvider::RECOVERY_ONLY) + .decrypt(&external_encrypted_xml()), + Err(XmlEncError::Policy( + xml_sec::policy::PolicyViolation::KeySize { + operation: "decryption", + actual_bits: 2048, + minimum_bits: 4096, + .. + } + )) + )); +} + +struct RecoveryMetadata { + bits: usize, + exponent: Option, + width: usize, +} + +impl KeyRecoveryKey for RecoveryMetadata { + fn rsa_modulus_bits(&self) -> usize { + self.bits + } + fn rsa_public_exponent(&self) -> Option { + self.exponent + } + fn ciphertext_len(&self) -> usize { + self.width + } + fn recover_with_provider( + &self, + _provider: &dyn CryptoProvider, + _parameters: &RsaOaepParameters, + _ciphertext: &[u8], + ) -> Result, ProviderError> { + panic!("invalid metadata must not reach key recovery") + } +} + +#[test] +fn recovery_metadata_is_checked_without_rounding_or_dispatch() { + // Byte width alone hides a too-small non-byte-aligned modulus. Zero, + // unsupported exponents, and contradictory widths must also fail closed. + for (bits, exponent, width) in [ + (2047, Some(65537), 256), + (0, Some(65537), 256), + (8193, Some(65537), 1025), + (2048, None, 256), + (2048, Some(2), 256), + (2048, Some(65537), 255), + ] { + let resolver = PrivateKeyDecryptor::provider_key(Arc::new(RecoveryMetadata { + bits, + exponent, + width, + })); + assert!(matches!( + DecryptContext::new(&resolver) + .provider(&ExternalProvider::RECOVERY_ONLY) + .decrypt(&external_encrypted_xml()), + Err(XmlEncError::Policy(_)) + )); + } +} + #[test] fn refused_public_capability_fails_before_provider_dispatch() { // A provider's capability declaration is authoritative. The facade must diff --git a/tools/xmlsec1/src/commands.rs b/tools/xmlsec1/src/commands.rs index c0431076..567e432c 100644 --- a/tools/xmlsec1/src/commands.rs +++ b/tools/xmlsec1/src/commands.rs @@ -3773,6 +3773,7 @@ impl DecryptionKeyResolver for NamedRecipientDecryptor { .resolve_key(provider, algorithm, Some(encrypted_key)) { Ok(key) => return Ok(key), + Err(error @ XmlEncError::Policy(_)) => return Err(error), Err(error) => last_error = Some(error), } } @@ -3786,6 +3787,24 @@ impl DecryptionKeyResolver for NamedRecipientDecryptor { encrypted_key: Option<&EncryptedKey>, budget: &mut KeyCandidateBudget, ) -> Result>, XmlEncError> { + self.resolve_key_candidates_with_policy( + provider, + algorithm, + encrypted_key, + &DecryptionPolicy::default(), + budget, + ) + } + + fn resolve_key_candidates_with_policy( + &self, + provider: &dyn CryptoProvider, + algorithm: DataEncryptionAlgorithm, + encrypted_key: Option<&EncryptedKey>, + policy: &DecryptionPolicy, + budget: &mut KeyCandidateBudget, + ) -> Result>, XmlEncError> { + policy.validate()?; let Some(encrypted_key) = encrypted_key else { return Err(XmlEncError::KeyNotFound); }; @@ -3793,11 +3812,14 @@ impl DecryptionKeyResolver for NamedRecipientDecryptor { let mut last_error = None; for key in self.applicable_keys(encrypted_key) { budget.consume(1)?; - match key - .inner - .resolve_key(provider, algorithm, Some(encrypted_key)) - { + match key.inner.resolve_key_with_policy( + provider, + algorithm, + Some(encrypted_key), + policy, + ) { Ok(key) => resolved.push(key), + Err(error @ XmlEncError::Policy(_)) => return Err(error), Err(error) => last_error = Some(error), } } From 5335a6f1673da8ad836cecee38a31384888e2af3 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 2 Oct 2026 02:22:07 +0300 Subject: [PATCH 8/9] fix(keys): bound PFX passwords and PEM formats Account for live password capacity and PBES2 preprocessing before derivation. Keep PUBLIC KEY imports SPKI-only while preserving generic DER certificate support, with boundary regressions and updated documentation. --- docs/key-management.md | 14 +++-- src/key_manager.rs | 75 ++++++++++++++++++++++- src/key_manager/pkcs12_import.rs | 100 +++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 6 deletions(-) diff --git a/docs/key-management.md b/docs/key-management.md index 2182f78f..297ac6f3 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -91,7 +91,10 @@ triggers an unprotected fallback. PEM private-key labels must match their payload: `ENCRYPTED PRIVATE KEY` cannot contain plaintext PKCS#8, and `PRIVATE KEY` cannot contain an encrypted container. RFC 7468 section 2 permits reinterpretation, but this API deliberately forbids it to preserve -the protected-key contract. Oversized encoded bundles return a typed +the protected-key contract. Similarly, `PUBLIC KEY` requires SubjectPublicKeyInfo +(RFC 7468 section 13); certificates are accepted through the certificate APIs +or generic DER import, not by reinterpreting a public-key PEM label. +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 @@ -105,11 +108,14 @@ and imported container; these checks precede password callbacks. The ciphertext- PKCS#8 decryption buffer also counts toward the peak, even when decryption fails. It is decrypted in place and retained without a second plaintext copy. For PEM imports, the encoded input and decoded DER coexist and both count toward that peak. -PKCS#8 passwords count toward per-resource and aggregate live-byte limits before +PKCS#8 and PKCS#12 passwords count toward per-resource and aggregate live-byte limits before derivation; callback buffers are charged by capacity, not just length. Work includes one unit per 64 password bytes per HMAC initialization (two passes for scrypt) -in addition to the KDF's derivation work. Plaintext -imports still ignore unused passwords. These limits are product policy, not format syntax. +in addition to the KDF's derivation work. Each PKCS#12 PBES2 derivation charges +password preprocessing to the shared budget, including encrypted nested bags; +legacy BMP password conversion consumes separate live memory when needed. +Plaintext PKCS#8 imports still ignore unused passwords. +These limits are product policy, not format syntax. 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 diff --git a/src/key_manager.rs b/src/key_manager.rs index 90af96e2..e07d4a1d 100644 --- a/src/key_manager.rs +++ b/src/key_manager.rs @@ -47,6 +47,11 @@ use crate::{ const XMLSEC_NS: &str = "http://www.aleksey.com/xmlsec/2002"; const XMLDSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#"; +enum PublicDerFormat { + KeyOrCertificate, + SubjectPublicKeyInfo, +} + fn check_selected_public_material( info: &KeyInfo, resources: &ResourcePolicy, @@ -1043,6 +1048,23 @@ impl KeyInventory { der: Vec, usages: Option, resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.add_public_der_payload( + name, + der, + usages, + resources, + PublicDerFormat::KeyOrCertificate, + ) + } + + fn add_public_der_payload( + &mut self, + name: String, + der: Vec, + usages: Option, + resources: &ResourcePolicy, + format: PublicDerFormat, ) -> Result<(), KeyStoreError> { self.check_new_name(&name, resources)?; let permitted = KeyUsages::VERIFY.union(KeyUsages::ENCRYPT); @@ -1071,6 +1093,14 @@ impl KeyInventory { .push(KeyInfoSource::DerEncodedKeyValue(der)); is_rsa } else { + if matches!(format, PublicDerFormat::SubjectPublicKeyInfo) { + // RFC 7468 section 13 identifies PUBLIC KEY as SPKI. Section 2 + // permits reinterpretation, but this label-dispatched API does + // not: certificate fallback belongs only to generic DER import. + // https://www.rfc-editor.org/rfc/rfc7468#section-13 + // https://www.rfc-editor.org/rfc/rfc7468#section-2 + return Err(KeyStoreError::Selection("invalid PUBLIC KEY payload")); + } let (rest, certificate) = X509Certificate::from_der(&der) .map_err(|_| KeyStoreError::Selection("invalid public key or X.509 certificate"))?; if !rest.is_empty() { @@ -1163,7 +1193,13 @@ impl KeyInventory { named_material_length(&name, bytes.len().max(der.len()), 2)?, resources, )?; - self.add_public_der_inner(name, der, usages, resources)?; + self.add_public_der_payload( + name, + der, + usages, + resources, + PublicDerFormat::SubjectPublicKeyInfo, + )?; debug_assert!(self.material_bytes >= previous_total); self.material_bytes = charged_total; Ok(()) @@ -1422,7 +1458,7 @@ impl KeyInventory { 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)?; + let contents = prepared.decrypt_with_password_capacity(&secret, secret.capacity())?; self.add_pkcs12_contents(name, bytes.len(), contents, usages, resources, false) } @@ -3606,6 +3642,41 @@ mod tests { ); } + #[test] + fn public_pem_label_rejects_certificate_payload() { + // Strict label dispatch must not inherit generic DER's certificate fallback. + let certificate = pem::parse(include_bytes!( + "../tests/fixtures/keys/rsa/rsa-2048-cert.pem" + )) + .expect("certificate") + .into_contents(); + let mislabeled = pem::encode(&pem::Pem::new("PUBLIC KEY", certificate.clone())); + let resources = ResourcePolicy::default(); + for usages in [None, Some(KeyUsages::VERIFY)] { + let mut inventory = KeyInventory::default(); + assert!(matches!( + inventory.add_public_pem_inner( + "key".into(), + mislabeled.as_bytes(), + usages, + &resources + ), + Err(KeyStoreError::Selection("invalid PUBLIC KEY payload")) + )); + assert!(inventory.public_keys().is_empty()); + } + KeyInventory::default() + .add_public_der("cert".into(), certificate, &resources) + .expect("generic DER intentionally accepts certificates"); + KeyInventory::default() + .add_public_pem( + "spki".into(), + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"), + &resources, + ) + .expect("correct SPKI label"); + } + #[test] fn rsa_spki_import_rejects_even_public_exponent() { // ASN.1 shape alone must not grant verify/encrypt usages to an unusable RSA key. diff --git a/src/key_manager/pkcs12_import.rs b/src/key_manager/pkcs12_import.rs index 2137e179..109c70bd 100644 --- a/src/key_manager/pkcs12_import.rs +++ b/src/key_manager/pkcs12_import.rs @@ -663,6 +663,14 @@ impl<'a> Encryption<'a> { let mut key = Zeroizing::new([0_u8; 32]); let mut iv = Zeroizing::new([0_u8; 16]); if let Some(hash) = self.hash { + // Product work accounting matches PKCS#8: charge password-keyed + // HMAC preprocessing for each derivation, including hidden bags. + let work = password.utf8.len().div_ceil(64); + let maximum = budget.limits.resources.max_key_import_kdf_work; + if work > maximum - budget.work { + return Err(denial(resource_name::KEY_IMPORT_KDF_WORK, maximum)); + } + budget.work += work; with_hash!( hash, D, @@ -1151,8 +1159,26 @@ pub(super) fn prepare<'a, 'l>(bytes: &'a [u8], limits: &'l Limits) -> Result
 {
     pub(super) fn decrypt(self, password: &str) -> Result {
+        self.decrypt_with_password_capacity(password, password.len())
+    }
+
+    pub(super) fn decrypt_with_password_capacity(
+        self,
+        password: &str,
+        capacity: usize,
+    ) -> Result {
         let Self { pfx, limits } = self;
         let mut budget = Budget::new(limits);
+        // The original UTF-8 buffer remains live alongside BER views, BMP
+        // conversion, and decrypted contents; a callback can retain spare capacity.
+        if password.len() > limits.resources.max_external_resource_bytes {
+            return Err(denial(
+                resource_name::EXTERNAL_RESOURCE_BYTES,
+                limits.resources.max_external_resource_bytes,
+            ));
+        }
+        debug_assert!(capacity >= password.len());
+        budget.allocate(capacity)?;
         budget.allocate(pfx.safe.owned_capacity())?;
         if let Some(mac) = &pfx.mac {
             budget.allocate(mac.salt.owned_capacity() + mac.digest.owned_capacity())?;
@@ -1745,6 +1771,80 @@ mod tests {
         }
     }
 
+    #[test]
+    fn pbes2_password_shares_work_and_live_memory_budget() {
+        // A no-MAC PFX still hashes its UTF-8 password before PBKDF2. Policy
+        // denial must precede derivation, and callback spare capacity is live.
+        let password = "p".repeat(256);
+        let private = pem::parse(include_bytes!(
+            "../../tests/fixtures/keys/rsa/rsa-2048-key.pem"
+        ))
+        .expect("key")
+        .into_contents();
+        let mut key = [0; 16];
+        let iv = [7; 16];
+        pbkdf2::pbkdf2_hmac::(password.as_bytes(), b"salt", 2, &mut key);
+        let mut output = vec![0; private.len() + 16];
+        let ciphertext = cbc::Encryptor::::new_from_slices(&key, &iv)
+            .expect("cipher")
+            .encrypt_padded_b2b::(&private, &mut output)
+            .expect("padding")
+            .to_vec();
+        let algorithm = sequence(&[
+            oid(PBES2),
+            sequence(&[
+                sequence(&[oid(PBKDF2), sequence(&[encoded(4, b"salt"), integer(2)])]),
+                sequence(&[oid(pkcs8::pkcs5::pbes2::AES_128_CBC_OID), encoded(4, &iv)]),
+            ]),
+        ]);
+        let bytes = pfx(&[data(&sequence(&[bag(
+            pkcs12::PKCS_12_PKCS8_KEY_BAG_OID,
+            &sequence(&[algorithm, encoded(4, &ciphertext)]),
+        )]))]);
+        let peak = password.len() + ciphertext.len() + core::mem::size_of::>>();
+        for (work, memory, per_resource) in [(5, peak, 256), (6, peak - 1, 256), (6, peak, 255)] {
+            let mut limits = limits(64);
+            limits.resources.max_key_import_kdf_work = work;
+            limits.resources.max_external_resource_bytes = per_resource;
+            limits.memory_available = memory;
+            assert!(matches!(
+                prepare(&bytes, &limits)
+                    .expect("visible KDF fits")
+                    .decrypt(&password),
+                Err(KeyStoreError::Policy(_))
+            ));
+        }
+        let mut exact = limits(64);
+        exact.resources.max_key_import_kdf_work = 6;
+        exact.memory_available = peak;
+        assert_eq!(
+            &*prepare(&bytes, &exact)
+                .expect("preflight")
+                .decrypt(&password)
+                .expect("exact password allowances")
+                .private_keys[0],
+            &private
+        );
+        let mut secret = String::with_capacity(4096);
+        secret.push_str(&password);
+        let resources = ResourcePolicy {
+            max_external_resource_total_bytes: bytes.len() + peak + 3,
+            ..ResourcePolicy::default()
+        };
+        let mut inventory = super::super::KeyInventory::default();
+        assert!(matches!(
+            inventory.add_pkcs12_with_password_callback(
+                "new".into(),
+                &bytes,
+                || Some(Zeroizing::new(secret)),
+                super::super::KeyUsages::SIGN,
+                &resources
+            ),
+            Err(KeyStoreError::Policy(_))
+        ));
+        assert!(inventory.private_keys().is_empty());
+    }
+
     #[test]
     fn pbes2_cipher_prf_matrix_accepts_utf8_passwords_without_legacy_kdf() {
         // RFC 8018 PBES2 does not impose RFC 7292's legacy BMP password

From 05918eec0ab208cfe6e93b69161ba719f7ca25bf Mon Sep 17 00:00:00 2001
From: Dmitry Prudnikov 
Date: Fri, 2 Oct 2026 03:31:55 +0300
Subject: [PATCH 9/9] fix(keys): preflight and share import budgets

Check import capacity and usages before parsing or password work. Share candidate and XML parsing charges across repeated key stores, preserving failed-attempt accounting.
---
 docs/key-management.md           |  11 ++
 src/key_manager.rs               | 280 ++++++++++++++++++++++++++-----
 src/key_manager/pkcs12_import.rs |  47 +++++-
 tools/xmlsec1/src/commands.rs    |  74 +++++++-
 4 files changed, 365 insertions(+), 47 deletions(-)

diff --git a/docs/key-management.md b/docs/key-management.md
index 297ac6f3..0b595302 100644
--- a/docs/key-management.md
+++ b/docs/key-management.md
@@ -53,6 +53,8 @@ material budget for every stored copy, including public-key `KeyName` metadata.
 Import and 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.
+Certificate and CRL imports check candidate and aggregate capacity before DER
+parsing, so an exhausted inventory returns a policy error even for malformed input.
 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
@@ -80,6 +82,7 @@ permission and a resolver selected under a weaker policy do not weaken a later o
 
 `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.
+Incompatible or empty private-key usages are rejected before requesting a password.
 `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.
@@ -114,6 +117,8 @@ one unit per 64 password bytes per HMAC initialization (two passes for scrypt)
 in addition to the KDF's derivation work. Each PKCS#12 PBES2 derivation charges
 password preprocessing to the shared budget, including encrypted nested bags;
 legacy BMP password conversion consumes separate live memory when needed.
+Ciphertext output capacity is checked before password derivation, including
+after lazy BMP conversion, without allocating the output until decryption needs it.
 Plaintext PKCS#8 imports still ignore unused passwords.
 These limits are product policy, not format syntax.
 The CLI applies the same pre-decryption KDF limits to explicit protected PKCS#8
@@ -132,6 +137,12 @@ another candidate resolved. Custom bag attributes accept BER high-tag-number
 identifiers (X.690 section 8.1.2.4) without retaining their values.
 Shared XMLDSig candidate accounting is constructed from the operation's
 `VerificationPolicy`, not a separate caller-supplied numeric limit.
+For multiple XML stores, use `XmlKeyStoreImporter::new(&policy, backend)`, call
+`import(bytes)` for each source, then `finish()` to obtain the inventory. The CLI
+uses this session for repeated `--keys-file`: candidate inspections and XML parsing
+work share one operation allowance rather than resetting for each file. Failed
+imports preserve existing keys but retain their work charges; retained material
+from earlier files reduces capacity before decoding the next source.
 BER PrivateKeyInfo framing and constructed private-key OCTET STRINGs are
 normalized to bounded PKCS#8 DER before storage. CMS EncryptedData accepts
 unprotected attributes with version 2, while requiring version 0 without them
diff --git a/src/key_manager.rs b/src/key_manager.rs
index e07d4a1d..9d742e08 100644
--- a/src/key_manager.rs
+++ b/src/key_manager.rs
@@ -173,6 +173,51 @@ pub struct KeyInventory {
     material_bytes: usize,
 }
 
+/// Import session for multiple XML key stores under one operation snapshot.
+/// Failed attempts retain their inspection and parser-work charges. Successful
+/// stores are moved into the inventory, never cloned.
+pub struct XmlKeyStoreImporter<'a, P: crate::document::XmlDocumentPolicy> {
+    policy: &'a P,
+    backend: XmlBackend,
+    parse_work: XmlParseWorkBudget,
+    inspected: usize,
+    inventory: KeyInventory,
+}
+
+impl<'a, P: crate::document::XmlDocumentPolicy> XmlKeyStoreImporter<'a, P> {
+    /// Bind this session to the caller's immutable policy and XML backend.
+    pub fn new(policy: &'a P, backend: XmlBackend) -> Result {
+        ensure_resource_policy(policy.resource_policy())?;
+        Ok(Self {
+            policy,
+            backend,
+            parse_work: XmlParseWorkBudget::from_resources(policy.resource_policy()),
+            inspected: 0,
+            inventory: KeyInventory::default(),
+        })
+    }
+
+    /// Import a file without resetting work budgets. Failure leaves stored keys
+    /// unchanged, but does not refund work already performed.
+    pub fn import(&mut self, bytes: &[u8]) -> Result<(), KeyStoreError> {
+        let store = KeyInventory::from_xml_bytes_with_budget(
+            bytes,
+            self.policy,
+            self.backend,
+            &self.parse_work,
+            &mut self.inspected,
+            self.inventory.material_bytes,
+        )?;
+        self.inventory.extend(store, self.policy.resource_policy())
+    }
+
+    /// Finish the import session and transfer ownership of the complete inventory.
+    #[must_use]
+    pub fn finish(self) -> KeyInventory {
+        self.inventory
+    }
+}
+
 /// Candidate inspections shared by named signing lookups in one operation.
 #[derive(Default)]
 pub struct SigningLookupBudget {
@@ -1237,12 +1282,7 @@ impl KeyInventory {
         }
         let retained_with_input =
             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",
-            ));
-        }
+        validate_private_key_usages(usages)?;
         let der = if PrivateKeyInfoRef::try_from(bytes).is_ok() {
             Zeroizing::new(bytes.to_vec())
         } else if let Ok(encrypted) = EncryptedPrivateKeyInfoRef::try_from(bytes) {
@@ -1321,6 +1361,7 @@ impl KeyInventory {
         F: FnOnce() -> Option>>,
     {
         self.check_new_name(&name, resources)?;
+        validate_private_key_usages(usages)?;
         if bytes.len() > resources.max_external_resource_bytes {
             return Err(import_resource_limit(
                 crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES,
@@ -1596,12 +1637,7 @@ impl KeyInventory {
         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",
-            ));
-        }
+        validate_private_key_usages(usages)?;
         if bytes.len() > resources.max_external_resource_bytes {
             return Err(KeyStoreError::Policy(
                 crate::policy::PolicyViolation::ResourceLimitExceeded {
@@ -1685,25 +1721,13 @@ impl KeyInventory {
         trusted_anchor: bool,
         resources: &ResourcePolicy,
     ) -> Result<(), KeyStoreError> {
-        ensure_resource_policy(resources)?;
-        if der.len() > resources.max_external_resource_bytes {
-            return Err(import_resource_limit(
-                crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES,
-                resources.max_external_resource_bytes,
-            ));
-        }
+        let material_bytes = self.preflight_x509_import(&der, resources)?;
         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(import_resource_limit(
-                crate::policy::resource_name::KEY_CANDIDATES,
-                resources.max_key_candidates,
-            ));
-        }
-        self.reserve_material(der.len(), resources)?;
+        self.material_bytes = material_bytes;
         if trusted_anchor {
             self.trusted_certificates.push(der);
         } else {
@@ -1719,6 +1743,24 @@ impl KeyInventory {
         der: Vec,
         resources: &ResourcePolicy,
     ) -> Result<(), KeyStoreError> {
+        let material_bytes = self.preflight_x509_import(&der, resources)?;
+        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"));
+        }
+        self.material_bytes = material_bytes;
+        self.crls.push(der);
+        self.entry_count += 1;
+        Ok(())
+    }
+
+    fn preflight_x509_import(
+        &self,
+        der: &[u8],
+        resources: &ResourcePolicy,
+    ) -> Result {
+        // Policy exhaustion is terminal before any candidate-local DER work.
         ensure_resource_policy(resources)?;
         if der.len() > resources.max_external_resource_bytes {
             return Err(import_resource_limit(
@@ -1726,27 +1768,31 @@ impl KeyInventory {
                 resources.max_external_resource_bytes,
             ));
         }
-        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(import_resource_limit(
                 crate::policy::resource_name::KEY_CANDIDATES,
                 resources.max_key_candidates,
             ));
         }
-        self.reserve_material(der.len(), resources)?;
-        self.crls.push(der);
-        self.entry_count += 1;
-        Ok(())
+        self.check_material_capacity(der.len(), resources)
     }
     /// 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 budget = XmlParseWorkBudget::from_resources(policy.resource_policy());
+        Self::from_xml_bytes_with_budget(bytes, policy, backend, &budget, &mut 0, 0)
+    }
+
+    fn from_xml_bytes_with_budget(
+        bytes: &[u8],
+        policy: &P,
+        backend: XmlBackend,
+        budget: &XmlParseWorkBudget,
+        inspected: &mut usize,
+        live_material: usize,
     ) -> Result {
         let resources = policy.resource_policy();
         ensure_resource_policy(resources)?;
@@ -1756,7 +1802,7 @@ impl KeyInventory {
                 resources.max_external_resource_bytes,
             ));
         }
-        if bytes.len() > resources.max_external_resource_total_bytes {
+        if bytes.len() > resources.max_external_resource_total_bytes - live_material {
             return Err(import_resource_limit(
                 crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES,
                 resources.max_external_resource_total_bytes,
@@ -1764,19 +1810,18 @@ impl KeyInventory {
         }
         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),
+            Some(budget),
         )
         .map_err(|error| match error.into_policy_violation(settings) {
             Ok(violation) => KeyStoreError::Policy(violation),
             Err(error) => KeyStoreError::Invalid(error.to_string()),
         })?;
-        let document = parse_borrowed_with_settings_and_budget(&text, settings, Some(&budget))
+        let document = parse_borrowed_with_settings_and_budget(&text, settings, Some(budget))
             .map_err(|error| match error.into_policy_violation(settings) {
                 Ok(violation) => KeyStoreError::Policy(violation),
                 Err(error) => KeyStoreError::Invalid(error.to_string()),
@@ -1792,12 +1837,13 @@ impl KeyInventory {
             if !info.has_tag_name((XMLDSIG_NS, "KeyInfo")) {
                 return Err(KeyStoreError::Invalid("unexpected child of Keys".into()));
             }
-            if entry_count >= resources.max_key_candidates {
+            if *inspected >= resources.max_key_candidates {
                 return Err(import_resource_limit(
                     crate::policy::resource_name::KEY_CANDIDATES,
                     resources.max_key_candidates,
                 ));
             }
+            *inspected += 1;
             entry_count += 1;
             let mut name = None;
             let mut value = None;
@@ -1938,7 +1984,7 @@ impl KeyInventory {
         // 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 {
+        if store.material_bytes > resources.max_external_resource_total_bytes - live_material {
             return Err(import_resource_limit(
                 crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES,
                 resources.max_external_resource_total_bytes,
@@ -1948,6 +1994,16 @@ impl KeyInventory {
     }
 }
 
+fn validate_private_key_usages(usages: KeyUsages) -> Result<(), KeyStoreError> {
+    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",
+        ));
+    }
+    Ok(())
+}
+
 fn import_resource_limit(resource: &'static str, maximum: usize) -> KeyStoreError {
     crate::policy::PolicyViolation::ResourceLimitExceeded { resource, maximum }.into()
 }
@@ -3220,6 +3276,63 @@ mod tests {
         ));
     }
 
+    #[test]
+    fn xml_import_session_keeps_failed_work_and_preflights_live_material() {
+        // Retrying malformed input cannot refund candidate inspections; a
+        // full live inventory rejects the next source before XML parsing.
+        let xml = |value: &str| {
+            format!(
+                "a{value}"
+            )
+        };
+        let policy = xml_policy(ResourcePolicy {
+            max_key_candidates: 1,
+            ..ResourcePolicy::default()
+        });
+        let mut importer =
+            XmlKeyStoreImporter::new(&policy, XmlBackend::default()).expect("session");
+        assert!(matches!(
+            importer.import(xml("!").as_bytes()),
+            Err(KeyStoreError::Invalid(_))
+        ));
+        assert!(matches!(
+            importer.import(xml("AA==").as_bytes()),
+            Err(KeyStoreError::Policy(
+                crate::policy::PolicyViolation::ResourceLimitExceeded {
+                    resource: crate::policy::resource_name::KEY_CANDIDATES,
+                    maximum: 1
+                }
+            ))
+        ));
+        assert_eq!(importer.finish().entry_count(), 0);
+        let valid = xml("AA==");
+        let policy = xml_policy(ResourcePolicy {
+            max_external_resource_total_bytes: valid.len(),
+            ..ResourcePolicy::default()
+        });
+        let mut importer =
+            XmlKeyStoreImporter::new(&policy, XmlBackend::default()).expect("session");
+        importer
+            .import(valid.as_bytes())
+            .expect("first source fits exactly");
+        let consumed = importer.parse_work.consumed();
+        assert!(matches!(
+            importer.import(b"!"),
+            Err(KeyStoreError::Policy(
+                crate::policy::PolicyViolation::ResourceLimitExceeded {
+                    resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES,
+                    ..
+                }
+            ))
+        ));
+        assert_eq!(
+            importer.parse_work.consumed(),
+            consumed,
+            "no parser work after byte denial"
+        );
+        assert_eq!(importer.finish().entry_count(), 1);
+    }
+
     #[test]
     fn xml_store_rejects_empty_symmetric_material() {
         // Empty decoded secrets are invalid at the same boundary as direct imports.
@@ -3889,6 +4002,91 @@ mod tests {
             .expect("plaintext import ignores password callback");
     }
 
+    #[test]
+    fn invalid_private_usages_do_not_request_password() {
+        // Caller-visible rejection must precede prompting or retrieving secrets.
+        let private = pem::parse(include_bytes!(
+            "../tests/fixtures/keys/rsa/rsa-2048-key.pem"
+        ))
+        .expect("key")
+        .into_contents();
+        let encrypted = PrivateKeyInfoRef::try_from(private.as_slice())
+            .expect("PKCS8")
+            .encrypt_with_rng(&mut ChaCha8Rng::seed_from_u64(42), b"correct")
+            .expect("encrypted PKCS8");
+        for usages in [
+            KeyUsages::VERIFY,
+            KeyUsages(0),
+            KeyUsages::SIGN.union(KeyUsages::VERIFY),
+        ] {
+            let calls = std::cell::Cell::new(0);
+            let mut inventory = KeyInventory::default();
+            assert!(matches!(
+                inventory.add_private_der_with_password_callback(
+                    "invalid".into(),
+                    encrypted.as_bytes(),
+                    || {
+                        calls.set(calls.get() + 1);
+                        Some(Zeroizing::new(b"correct".to_vec()))
+                    },
+                    usages,
+                    &ResourcePolicy::default()
+                ),
+                Err(KeyStoreError::Selection(
+                    "private key usage is incompatible"
+                ))
+            ));
+            assert_eq!(calls.get(), 0);
+            assert_eq!(inventory.entry_count(), 0);
+        }
+    }
+
+    #[test]
+    fn certificate_and_crl_capacity_precedes_der_parsing() {
+        // Exhausted inventory limits are terminal policy errors even for
+        // malformed DER. Failed imports must not change retained accounting.
+        for candidates in [true, false] {
+            for certificate in [true, false] {
+                let mut inventory = KeyInventory::default();
+                let resources = ResourcePolicy {
+                    max_key_candidates: if candidates { 1 } else { 64 },
+                    max_external_resource_total_bytes: 2,
+                    ..ResourcePolicy::default()
+                };
+                inventory
+                    .add_symmetric(
+                        "a".into(),
+                        SymmetricKeyKind::Hmac,
+                        vec![0],
+                        KeyUsages::SIGN,
+                        &resources,
+                    )
+                    .expect("fill inventory");
+                let result = if certificate {
+                    inventory.add_certificate_der(vec![0], false, &resources)
+                } else {
+                    inventory.add_crl_der(vec![0], &resources)
+                };
+                let resource = if candidates {
+                    crate::policy::resource_name::KEY_CANDIDATES
+                } else {
+                    crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES
+                };
+                assert!(matches!(result, Err(KeyStoreError::Policy(
+                    crate::policy::PolicyViolation::ResourceLimitExceeded { resource: actual, .. }
+                )) if actual == resource));
+                assert_eq!(inventory.entry_count(), 1);
+                assert_eq!(inventory.material_bytes, 2);
+                assert!(inventory.lookup_certificates.is_empty());
+                assert!(inventory.crls.is_empty());
+            }
+        }
+        assert!(matches!(
+            KeyInventory::default().add_crl_der(vec![0], &ResourcePolicy::default()),
+            Err(KeyStoreError::Selection("invalid X.509 CRL"))
+        ));
+    }
+
     #[test]
     fn pkcs8_pbkdf2_output_blocks_are_checked_before_password() {
         use der::Encode as _;
diff --git a/src/key_manager/pkcs12_import.rs b/src/key_manager/pkcs12_import.rs
index 109c70bd..79f63a56 100644
--- a/src/key_manager/pkcs12_import.rs
+++ b/src/key_manager/pkcs12_import.rs
@@ -52,7 +52,7 @@ impl<'a> Budget<'a> {
         }
     }
 
-    fn allocate(&mut self, size: usize) -> Result<()> {
+    fn check_allocation(&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(
@@ -60,6 +60,11 @@ impl<'a> Budget<'a> {
                 maximum,
             ));
         }
+        Ok(())
+    }
+
+    fn allocate(&mut self, size: usize) -> Result<()> {
+        self.check_allocation(size)?;
         self.memory += size;
         Ok(())
     }
@@ -660,6 +665,9 @@ impl<'a> Encryption<'a> {
         if ciphertext.is_empty() || !ciphertext.len().is_multiple_of(self.cipher.block()) {
             return malformed();
         }
+        // Preflight output before KDF work; allocate/charge it only when live,
+        // rather than invent overlap with the legacy KDF's temporary workspace.
+        budget.check_allocation(ciphertext.len())?;
         let mut key = Zeroizing::new([0_u8; 32]);
         let mut iv = Zeroizing::new([0_u8; 16]);
         if let Some(hash) = self.hash {
@@ -684,6 +692,7 @@ impl<'a> Encryption<'a> {
             iv.copy_from_slice(&self.iv);
         } else {
             let bmp = password.bmp(budget)?;
+            budget.check_allocation(ciphertext.len())?;
             budget.legacy_workspace(&self.salt, bmp, 64, self.cipher.key_len())?;
             let derived = Zeroizing::new(derive_key::(
                 bmp,
@@ -1248,6 +1257,42 @@ mod tests {
         }
     }
 
+    #[test]
+    fn ciphertext_capacity_is_checked_before_password_derivation() {
+        // An already-live callback buffer must stop both PBES2 and legacy KDF
+        // paths before any password preprocessing or BMP conversion.
+        for hash in [Some(Hash::Sha1), None] {
+            let mut limits = limits(64);
+            limits.memory_available = 31;
+            let mut budget = Budget::new(&limits);
+            budget.allocate(16).expect("live password capacity");
+            let mut password = Password {
+                utf8: "password",
+                bmp: None,
+            };
+            let encryption = Encryption {
+                cipher: if hash.is_some() {
+                    Cipher::Aes128
+                } else {
+                    Cipher::TripleDes
+                },
+                salt: Bytes::Borrowed(b"salt"),
+                rounds: 2,
+                hash,
+                iv: [0; 16],
+            };
+            assert!(matches!(
+                encryption.decrypt(&[0; 16], &mut password, &mut budget),
+                Err(KeyStoreError::Policy(_))
+            ));
+            assert_eq!(budget.work, 0, "no password hashing before memory denial");
+            assert!(
+                password.bmp.is_none(),
+                "no lazy BMP allocation before denial"
+            );
+        }
+    }
+
     #[test]
     fn ber_private_key_info_is_normalized_before_storage() {
         // KeyBag inherits the PFX BER contract; an indefinite SEQUENCE and
diff --git a/tools/xmlsec1/src/commands.rs b/tools/xmlsec1/src/commands.rs
index 567e432c..cb4f3823 100644
--- a/tools/xmlsec1/src/commands.rs
+++ b/tools/xmlsec1/src/commands.rs
@@ -711,15 +711,13 @@ fn load_xml_key_stores(
     backend: XmlBackend,
     budget: &mut ExternalMaterialBudget,
 ) -> Result {
-    let resources = policy.resource_policy();
-    let mut all = KeyInventory::default();
+    let mut importer = key_manager::XmlKeyStoreImporter::new(policy, backend)?;
     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)?;
+        importer.import(&bytes)?;
     }
-    Ok(all)
+    Ok(importer.finish())
 }
 
 fn select_store_candidates<'a, T>(
@@ -4260,6 +4258,72 @@ mod tests {
         Invocation::parse(arguments.iter().map(OsString::from)).unwrap()
     }
 
+    #[test]
+    fn repeated_key_files_share_candidate_and_parser_budgets() {
+        // The third entry must fail the operation budget before its malformed
+        // key is decoded; XML parser work must not reset between files either.
+        let temp = tempfile::tempdir().expect("test directory");
+        let first = temp.path().join("first.xml");
+        let second = temp.path().join("second.xml");
+        let entry = |name: &str, value: &str| {
+            format!(
+                "{name}{value}"
+            )
+        };
+        let store = |entries: String| {
+            format!(
+                "{entries}"
+            )
+        };
+        let a = store(entry("a", "AA=="));
+        fs::write(&first, &a).expect("first store");
+        fs::write(&second, store(entry("b", "AA==") + &entry("c", "!"))).expect("second store");
+        let invocation = invocation(&[
+            "xmlsec1",
+            "sign",
+            "--keys-file",
+            first.to_str().unwrap(),
+            "--keys-file",
+            second.to_str().unwrap(),
+            "template.xml",
+        ]);
+        let mut policy = xml_sec::policy::VerificationPolicy::default();
+        policy.resources.max_key_candidates = 2;
+        let mut budget =
+            ExternalMaterialBudget::new(policy.resources.max_external_resource_total_bytes);
+        assert!(matches!(
+            load_xml_key_stores(&invocation, &policy, XmlBackend::default(), &mut budget),
+            Err(CommandError::KeyStore(key_manager::KeyStoreError::Policy(
+                xml_sec::policy::PolicyViolation::ResourceLimitExceeded {
+                    resource: "key candidates",
+                    maximum: 2
+                }
+            )))
+        ));
+        fs::write(&second, store(entry("b", "AA=="))).expect("valid second store");
+        let mut budget =
+            ExternalMaterialBudget::new(policy.resources.max_external_resource_total_bytes);
+        assert_eq!(
+            load_xml_key_stores(&invocation, &policy, XmlBackend::default(), &mut budget)
+                .expect("exact candidate boundary")
+                .entry_count(),
+            2
+        );
+        // Enough for either document individually, not both decoding/parsing passes.
+        policy.resources.max_xml_parse_work_bytes = a.len() * 3;
+        let mut budget =
+            ExternalMaterialBudget::new(policy.resources.max_external_resource_total_bytes);
+        assert!(matches!(
+            load_xml_key_stores(&invocation, &policy, XmlBackend::default(), &mut budget),
+            Err(CommandError::KeyStore(key_manager::KeyStoreError::Policy(
+                xml_sec::policy::PolicyViolation::ResourceLimit {
+                    resource: "cumulative XML parse-work bytes",
+                    ..
+                }
+            )))
+        ));
+    }
+
     #[test]
     fn explicit_pkcs8_signing_enforces_import_kdf_limits() {
         // Explicit PEM/DER options, including generic private-key aliases, must