From 8644a2340ed99fa91b0199ed134500d2301c1c9c Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 2 Oct 2026 09:09:10 +0300 Subject: [PATCH 1/9] feat(keys): add caller-owned key inventory Import named keys, protected bundles, certificates and CRLs under shared operation policy and resource budgets. Wire inventory selection into signing, verification, encryption and decryption with negative, boundary and reciprocal interoperability coverage. Closes #167 --- .github/workflows/ci.yml | 2 +- Cargo.toml | 8 + README.md | 6 + crates/xml-sec-xslt/src/model.rs | 1 - docs/cli.md | 10 + docs/key-management.md | 210 + docs/xmlenc.md | 10 +- src/document.rs | 23 +- src/hard_limits.rs | 11 + src/key_manager.rs | 6395 +++++++++++++++++ src/key_manager/pkcs12_import.rs | 1975 +++++ src/lib.rs | 2 + src/policy.rs | 96 +- src/provider.rs | 37 +- src/sxd_xpath/function.rs | 2 +- src/xmldsig/keys.rs | 766 +- src/xmldsig/mod.rs | 4 +- src/xmldsig/sign.rs | 51 +- src/xmldsig/signature.rs | 48 +- src/xmldsig/x509.rs | 38 +- src/xmlenc/decrypt.rs | 84 +- 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/provider_contract.rs | 83 +- tests/xmlenc_encrypt_xmlsec1.rs | 40 + tools/xmlsec1/src/args.rs | 1 + tools/xmlsec1/src/commands.rs | 1476 +++- tools/xmlsec1/src/key_material.rs | 278 +- tools/xmlsec1/tests/process_contract.rs | 1217 +++- 35 files changed, 12683 insertions(+), 268 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..9a094dbc 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 @@ -104,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/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..0b595302 --- /dev/null +++ b/docs/key-management.md @@ -0,0 +1,210 @@ +# 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. +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 +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. +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. +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. +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. 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. 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 +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 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. +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. 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 +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 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. +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 +(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 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. + +```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. +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 +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/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/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..9d742e08 --- /dev/null +++ b/src/key_manager.rs @@ -0,0 +1,6395 @@ +//! 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#"; + +enum PublicDerFormat { + KeyOrCertificate, + SubjectPublicKeyInfo, +} + +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, +} + +/// 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 { + 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); + 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. + 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 outcome = prefix_resolver.resolve_sources_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) + }, + )?; + if let Some(key) = outcome.key { + return Ok(Some(key)); + } + prefix_error = outcome.deferred_error; + } + 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(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 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( + &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(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(import_resource_limit( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_total_bytes, + )); + } + 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)?; + // 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))) + } + /// 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.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() + || (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.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); + 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(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)?; + 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 { + 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() { + 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_payload( + name, + der, + usages, + resources, + PublicDerFormat::SubjectPublicKeyInfo, + )?; + 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.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 { + return Err(import_resource_limit( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_bytes, + )); + } + let retained_with_input = + self.check_material_capacity(named_material_length(&name, bytes.len(), 1)?, resources)?; + 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) { + // 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)?; + 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()); + let plaintext_len = encrypted + .encryption_algorithm + .decrypt_in_place(password, &mut plain) + .map_err(|_| KeyStoreError::ProtectedContainer)?; + 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) + .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(import_resource_limit( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_bytes, + )); + } + 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)?; + 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, + resources.max_external_resource_bytes, + )); + } + let retained_with_input = + self.check_material_capacity(named_material_length(&name, bytes.len(), 1)?, resources)?; + 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, + 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)?; + 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")); + } + } + let previous_total = self.material_bytes; + let name_len = name.len(); + 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(()) + } + + /// 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_with_password_capacity(&secret, secret.capacity())?; + 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)?; + validate_private_key_usages(usages)?; + 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, + }, + )); + } + 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, + // Borrowing the PFX does not end its lifetime during decryption. + memory_available: resources.max_external_resource_total_bytes - retained_with_input, + }) + } + + fn check_new_name(&self, name: &str, resources: &ResourcePolicy) -> Result<(), KeyStoreError> { + ensure_resource_policy(resources)?; + 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(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) + || 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_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(import_resource_limit( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_total_bytes, + )); + } + 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> { + 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")); + } + self.material_bytes = material_bytes; + 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> { + 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( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_bytes, + )); + } + if self.entry_count >= resources.max_key_candidates { + return Err(import_resource_limit( + crate::policy::resource_name::KEY_CANDIDATES, + resources.max_key_candidates, + )); + } + 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)?; + 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 - live_material { + 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) + .with_backend(backend); + 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| 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| 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())); + } + 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 *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; + 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 - live_material { + return Err(import_resource_limit( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_total_bytes, + )); + } + Ok(store) + } +} + +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() +} + +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(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"))? + .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, + retained_with_input: usize, +) -> 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); + }; + // 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 { + return Err(KeyStoreError::ProtectedContainer); + } + 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, + work, + )); + } + } + Kdf::Scrypt(kdf) => { + enforce_scrypt_kdf_limits( + kdf.cost_parameter, + u64::from(kdf.block_size), + u64::from(kdf.parallelization), + resources, + live_with_output, + )?; + } + _ => return Err(KeyStoreError::ProtectedContainer), + } + 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, + p: u64, + resources: &ResourcePolicy, + retained_with_input: usize, +) -> 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, + )); + } + // 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(()) +} + +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() { + // 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: 4, + ..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_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. + 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::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + .. + } + )) + )); + 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, 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', "") + .replace("test-dsa", &"name".repeat(512)) + ); + 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()); + 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] + 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. + 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_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. + 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_and(|error| matches!(error, KeyStoreError::Policy(_))), + "parser policy denial must retain its type in 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 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. + 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 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 + // 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 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 _; + // 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 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 _; + 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 _; + 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() + 16 + 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() + + ciphertext.len() + + 2304 + + b"correct".len(), + ..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 _; + // 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 + + 16 + + 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_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 + // 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. + 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, 0), + 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, 0).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::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + .. + } + )) + ), + "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_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() { + // 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::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + .. + } + )) + )); + 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..79f63a56 --- /dev/null +++ b/src/key_manager/pkcs12_import.rs @@ -0,0 +1,1975 @@ +//! 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, + candidates: 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, + candidates: 0, + } + } + + 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( + resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + maximum, + )); + } + Ok(()) + } + + fn allocate(&mut self, size: usize) -> Result<()> { + self.check_allocation(size)?; + 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) -> Result<()> { + if self.candidates >= self.limits.candidates { + return Err(denial( + resource_name::KEY_CANDIDATES, + self.limits.candidates, + )); + } + self.candidates += 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 == 0 { + return malformed(); + } + 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 length_octet == 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(length_octet); + 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 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. + // 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(); + } + Ok(bytes) + } + fn integer(&mut self) -> Result { + let bytes = self.nonnegative_integer()?; + 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 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(); + } + 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.kdf_iterations(budget)? + }; + 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: [u8; 16], +} +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.kdf_iterations(budget)?; + 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", + )); + }; + 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 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.kdf_iterations(budget)?; + hash = None; + 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)?; + } + 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(); + } + // 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 { + // 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, + pbkdf2::pbkdf2_hmac::( + password.utf8.as_bytes(), + &self.salt, + self.rounds, + &mut key[..self.cipher.key_len()] + ) + ); + 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, + &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); + 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(); + } + 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 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-5.3 + 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 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<'_>, + 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()?; + 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); + 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); + } 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(normalize_private_key(value, budget)?); + } + } 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()?; + 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 { + 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())?; + 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 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 + // 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")), + ]); + // 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), + (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 + // 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 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 + // 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 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. + 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_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_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 + // 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..32ff0661 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 { @@ -223,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, } @@ -325,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) { @@ -336,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, @@ -353,7 +388,7 @@ impl RsaKeyPolicy { reason: "public exponent is outside the supported odd range", }); } - Ok(modulus.len()) + Ok(modulus_bits.div_ceil(8)) } } @@ -412,6 +447,11 @@ 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, 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, /// Maximum nested `KeyInfoReference` dereference depth. pub max_key_info_reference_depth: usize, /// Maximum bytes accepted by Base64 transforms before decoding. @@ -469,6 +509,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 +620,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, @@ -1176,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. @@ -1186,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() } } @@ -1315,6 +1371,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, @@ -1439,6 +1505,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..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() @@ -908,7 +942,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..7d1616f0 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,76 @@ pub struct DefaultKeyResolver { config: KeyResolverConfig, } +#[derive(Clone, Copy)] +pub(crate) enum ResolutionScope { + Document, + 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. /// /// 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 { +/// 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 { - fn new(maximum: usize) -> Self { + /// Start shared accounting derived solely from the operation policy. + #[must_use] + pub fn new(policy: &crate::policy::VerificationPolicy) -> Self { Self { - maximum, + maximum: policy.resources.max_key_candidates, attempted: 0, } } - 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) } - 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> { + 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, - actual: self.attempted, + actual: self.attempted.saturating_add(count), } .into()); } + debug_assert!(self.attempted <= self.maximum); + 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 +563,67 @@ fn validate_key_info_source_permissions( } impl DefaultKeyResolver { + /// 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, + policy: &crate::policy::VerificationPolicy, + 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, + 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_sources_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 { + self.resolve_source_range( + 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 +636,59 @@ impl DefaultKeyResolver { &self.config } + 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 charge_crls && 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; + // 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, + 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 +750,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 +1126,85 @@ 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> { + 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::DocumentSuffix(_) + ); + 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 source_start = match scope { + ResolutionScope::DocumentSuffix(start) | ResolutionScope::TrustedSuffix(start) => start, + _ => 0, }; - validate_key_info_source_permissions(key_info, sources)?; - let mut candidate_budget = InspectedKeyCandidateBudget::new(resources.max_key_candidates); 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_start..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 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)? } KeyInfoSource::DerEncodedKeyValue(public_key_bytes) => { candidate_budget.charge()?; @@ -1037,17 +1251,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, + }) } } @@ -1058,13 +1275,14 @@ impl KeyResolver for DefaultKeyResolver { algorithm: SignatureAlgorithm, ) -> Result>, DsigError> { let policy = crate::policy::VerificationPolicy::default(); + let mut candidate_budget = InspectedKeyCandidateBudget::new(&policy); 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 +1307,13 @@ 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); + self.resolve_with_candidate_budget( key_info, algorithm, - policy.key_sources, - &policy.key_trust, - &policy.resources, + policy, provider, + &mut candidate_budget, ) } @@ -1151,6 +1369,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,12 +1380,48 @@ 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], 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), @@ -1234,8 +1489,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 +1507,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 +1610,154 @@ mod tests { use super::*; + #[test] + fn shared_candidate_budget_cannot_relax_active_policy() { + // 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 { + sources: vec![KeyInfoSource::KeyName("missing".into())], + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.resources.max_key_candidates = 1; + let mut budget = InspectedKeyCandidateBudget::new(&policy); + 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(&crate::policy::VerificationPolicy::default()); + 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 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. + 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 +3809,204 @@ 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 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 + // 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/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/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..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) } @@ -1193,7 +1262,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 { @@ -1659,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/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/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/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..cb4f3823 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, @@ -24,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::{ @@ -69,7 +71,9 @@ const SIGN_OPTIONS: &[&str] = &[ "privkey-der", "pkcs8-pem", "pkcs8-der", + "pkcs12", "hmac-key", + "keys-file", "pwd", "lax-key-search", "node-id", @@ -89,6 +93,7 @@ const VERIFY_OPTIONS: &[&str] = &[ "pubkey-cert-pem", "pubkey-cert-der", "hmac-key", + "keys-file", "trusted-pem", "trusted-der", "untrusted-pem", @@ -120,6 +125,7 @@ const ENCRYPT_OPTIONS: &[&str] = &[ "binary-data", "xml-data", "aes-key", + "keys-file", "pubkey-pem", "pubkey-der", "pubkey-cert-pem", @@ -137,10 +143,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 +202,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 +705,66 @@ fn named_candidate_search<'a, T: Copy>( ) } +fn load_xml_key_stores( + invocation: &Invocation, + policy: &P, + backend: XmlBackend, + budget: &mut ExternalMaterialBudget, +) -> Result { + 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)?; + importer.import(&bytes)?; + } + Ok(importer.finish()) +} + +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 +783,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 +939,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 +985,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 +1002,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 +1069,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 +1408,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 +1422,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 +1458,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 +1512,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 +1664,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) { @@ -1559,6 +1894,8 @@ 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); let document_crls = key_info .into_iter() .flat_map(|info| &info.sources) @@ -1566,9 +1903,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 @@ -1582,32 +1918,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; } @@ -1618,7 +1961,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), } } @@ -1783,6 +2128,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 +2189,224 @@ 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 @ CommandError::KeyStore(key_manager::KeyStoreError::Policy(_))) => { + return Err(error); + } + 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 +2687,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 +2981,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 +3062,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 +3224,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 +3425,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 +3437,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 +3478,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 +3496,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 +3555,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 +3642,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 +3715,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 +3741,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) } @@ -3060,6 +3771,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), } } @@ -3073,6 +3785,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); }; @@ -3080,11 +3810,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), } } @@ -3517,12 +4250,174 @@ 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 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 + // 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 +4464,383 @@ 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(); + 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 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"); + 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>, @@ -3663,6 +4935,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 @@ -3984,6 +5308,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..d9213ea8 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 => { @@ -511,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()))?; @@ -542,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( @@ -576,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()); @@ -588,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( @@ -643,8 +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(|_| 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( @@ -652,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()); @@ -673,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())); @@ -684,9 +725,13 @@ 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()))?; - decrypt_openssl_legacy_pem(cipher, &iv, envelope.contents(), password, path) + let password = password.ok_or(KeyMaterialError::ProtectedContainer)?; + decrypt_openssl_legacy_pem(cipher, &iv, envelope.contents(), password, path).map(|der| { + TraditionalPemKey { + der, + encrypted: true, + } + }) } fn decode_hex(value: &str) -> Option> { @@ -735,7 +780,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 +939,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 +1115,96 @@ 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 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. + 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..8a5a17d2 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,269 @@ 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. + 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 +708,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 +989,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"); @@ -2968,76 +3402,399 @@ fn output_template_expands_the_extensionless_input_basename() { .args(["sign", "--privkey-pem"]) .arg(private_key) .arg("--output") - .arg(output_template) - .arg(template) + .arg(output_template) + .arg(template) + .output() + .unwrap(); + + assert!( + signed.status.success(), + "{}", + String::from_utf8_lossy(&signed.stderr) + ); + assert!(expected.is_file()); +} + +#[test] +fn repeated_output_options_fail_before_creating_files() { + // Canonical and alias spellings identify one donor singleton; accepting + // both would silently redirect output through last-value wins behavior. + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("first.xml"); + let second = temp.path().join("second.xml"); + let template = project_root() + .join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl"); + let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem"); + let output = Command::new(binary()) + .args(["sign", "--privkey-pem"]) + .arg(&private_key) + .arg("--output") + .arg(&first) + .arg("-o") + .arg(&second) + .arg(&template) + .output() + .unwrap(); + + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("cannot be repeated")); + assert!(!first.exists()); + assert!(!second.exists()); +} + +#[test] +fn encrypts_decrypts_and_rejects_wrong_symmetric_key() { + // A reciprocal binary round trip must preserve non-UTF-8 bytes, while an + // authenticated GCM decrypt with the wrong key must fail. + let temp = tempfile::tempdir().unwrap(); + let template = temp.path().join("template.xml"); + let plaintext = temp.path().join("plaintext.bin"); + let key = temp.path().join("key.bin"); + let wrong_key = temp.path().join("wrong-key.bin"); + let encrypted = temp.path().join("encrypted.xml"); + let decrypted = temp.path().join("decrypted.bin"); + fs::write( + &template, + r#" + +"#, + ) + .unwrap(); + fs::write(&plaintext, b"process-level binary payload\0\xff").unwrap(); + // xmlsec1 treats --aeskey input as raw bytes even when all bytes happen to + // be valid Base64 characters. + fs::write(&key, b"0123456789abcdef").unwrap(); + fs::write(&wrong_key, b"fedcba9876543210").unwrap(); + + let encrypt = Command::new(binary()) + .args(["encrypt", "--aeskey:content"]) + .arg(&key) + .args(["--binary-data"]) + .arg(&plaintext) + .args(["--output"]) + .arg(&encrypted) + .arg(&template) + .output() + .unwrap(); + assert!( + encrypt.status.success(), + "{}", + String::from_utf8_lossy(&encrypt.stderr) + ); + + let decrypt = Command::new(binary()) + .args(["decrypt", "--aeskey"]) + .arg(&key) + .args(["--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()); + + let rejected = Command::new(binary()) + .args(["decrypt", "--aeskey"]) + .arg(&wrong_key) + .arg(&encrypted) + .output() + .unwrap(); + 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!( - signed.status.success(), + lax_encrypt.status.success(), "{}", - String::from_utf8_lossy(&signed.stderr) + String::from_utf8_lossy(&lax_encrypt.stderr) ); - assert!(expected.is_file()); -} + 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()); -#[test] -fn repeated_output_options_fail_before_creating_files() { - // Canonical and alias spellings identify one donor singleton; accepting - // both would silently redirect output through last-value wins behavior. - let temp = tempfile::tempdir().unwrap(); - let first = temp.path().join("first.xml"); - let second = temp.path().join("second.xml"); - let template = project_root() - .join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl"); - let private_key = project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem"); - let output = Command::new(binary()) - .args(["sign", "--privkey-pem"]) - .arg(&private_key) - .arg("--output") - .arg(&first) - .arg("-o") - .arg(&second) - .arg(&template) + 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") + ); - assert!(!output.status.success()); - assert!(String::from_utf8_lossy(&output.stderr).contains("cannot be repeated")); - assert!(!first.exists()); - assert!(!second.exists()); + 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 encrypts_decrypts_and_rejects_wrong_symmetric_key() { - // A reciprocal binary round trip must preserve non-UTF-8 bytes, while an - // authenticated GCM decrypt with the wrong key must fail. +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 key = temp.path().join("key.bin"); - let wrong_key = temp.path().join("wrong-key.bin"); let encrypted = temp.path().join("encrypted.xml"); - let decrypted = temp.path().join("decrypted.bin"); fs::write( &template, - r#" - -"#, + r#"selected-old"#, ) .unwrap(); - fs::write(&plaintext, b"process-level binary payload\0\xff").unwrap(); - // xmlsec1 treats --aeskey input as raw bytes even when all bytes happen to - // be valid Base64 characters. - fs::write(&key, b"0123456789abcdef").unwrap(); - fs::write(&wrong_key, b"fedcba9876543210").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", "--aeskey:content"]) - .arg(&key) - .args(["--binary-data"]) + .args(["encrypt", "--lax-key-search", "--keys-file"]) + .arg(&store) + .arg("--binary-data") .arg(&plaintext) - .args(["--output"]) + .arg("--output") .arg(&encrypted) .arg(&template) .output() @@ -3047,12 +3804,22 @@ fn encrypts_decrypts_and_rejects_wrong_symmetric_key() { "{}", 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", "--aeskey"]) - .arg(&key) - .args(["--output"]) - .arg(&decrypted) + .args(["decrypt", "--keys-file"]) + .arg(&store) .arg(&encrypted) .output() .unwrap(); @@ -3061,15 +3828,304 @@ fn encrypts_decrypts_and_rejects_wrong_symmetric_key() { "{}", String::from_utf8_lossy(&decrypt.stderr) ); - assert_eq!(fs::read(&decrypted).unwrap(), fs::read(&plaintext).unwrap()); + assert_eq!(decrypt.stdout, b"lax content key fallback"); +} - let rejected = Command::new(binary()) - .args(["decrypt", "--aeskey"]) - .arg(&wrong_key) +#[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!(!rejected.status.success()); + 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] @@ -5276,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 a04c56dadaf40f04eb0d616107975cfa7711f5f8 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 2 Oct 2026 10:29:16 +0300 Subject: [PATCH 2/9] fix(keys): preflight plaintext import copies --- docs/key-management.md | 8 +++ src/key_manager.rs | 114 +++++++++++++++++++++++++++++-- src/key_manager/pkcs12_import.rs | 37 ++++++++-- 3 files changed, 149 insertions(+), 10 deletions(-) diff --git a/docs/key-management.md b/docs/key-management.md index 0b595302..5365aa74 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -111,12 +111,20 @@ 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. +Plaintext imports also preflight the retained DER copy alongside the still-live +input and decoded PEM. PKCS#1 normalization wraps borrowed octets directly in +one PKCS#8 output rather than re-encoding big integers into intermediate buffers. 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. 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. +The legacy PKCS#12 MAC uses RFC 7292 B.1 BMPString password formatting even +when encryption uses PBES2. Supplementary Unicode characters therefore work +only when no legacy MAC or encryption KDF requires BMPString. UTF-16 surrogate +pairs used by some implementations are not accepted as BMPString; RFC 9879 +section 6 separately specifies UTF-8 for PBMAC1, which is not implemented here. 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. diff --git a/src/key_manager.rs b/src/key_manager.rs index 9d742e08..11ade499 100644 --- a/src/key_manager.rs +++ b/src/key_manager.rs @@ -18,7 +18,7 @@ use pkcs12_import::Limits as Pkcs12Limits; use rsa::pkcs8::DecodePublicKey as _; use rsa::{ RsaPrivateKey, RsaPublicKey, - pkcs1::{DecodeRsaPrivateKey as _, DecodeRsaPublicKey as _}, + pkcs1::DecodeRsaPublicKey as _, pkcs8::{ DecodePrivateKey as _, EncodePublicKey as _, EncryptedPrivateKeyInfoRef, PrivateKeyInfoRef, }, @@ -1283,7 +1283,29 @@ impl KeyInventory { let retained_with_input = self.check_material_capacity(named_material_length(&name, bytes.len(), 1)?, resources)?; validate_private_key_usages(usages)?; + // Borrowed input and decoded PEM remain live while an owned output is + // created. Preflight that peak, not only the final inventory footprint. + let check_plain_output = |output: usize| { + if output > resources.max_external_resource_bytes { + return Err(import_resource_limit( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_bytes, + )); + } + let live = bytes + .len() + .checked_add(live_encoded_bytes) + .and_then(|live| live.checked_add(output)) + .ok_or_else(|| { + import_resource_limit( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_total_bytes, + ) + })?; + self.check_material_capacity(named_material_length(&name, live, 1)?, resources) + }; let der = if PrivateKeyInfoRef::try_from(bytes).is_ok() { + check_plain_output(bytes.len())?; Zeroizing::new(bytes.to_vec()) } else if let Ok(encrypted) = EncryptedPrivateKeyInfoRef::try_from(bytes) { // PEM decoding does not end the caller's encoded buffer lifetime. @@ -1312,13 +1334,27 @@ impl KeyInventory { .map_err(|_| KeyStoreError::ProtectedContainer)?; plain } else { + use der::Encode as _; 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() + // RFC 8017 A.1.2 / RFC 5958 section 2: wrap the original PKCS#1 + // octets directly, avoiding bigint re-encoding and two owned DERs. + // https://www.rfc-editor.org/rfc/rfc5958#section-2 + let normalized = PrivateKeyInfoRef::new( + rsa::pkcs1::ALGORITHM_ID, + der::asn1::OctetStringRef::new(bytes) + .map_err(|_| KeyStoreError::Selection("invalid RSA private key"))?, + ); + let size = usize::try_from( + normalized + .encoded_len() + .map_err(|_| KeyStoreError::Selection("invalid RSA private key"))?, + ) + .map_err(|_| KeyStoreError::Selection("key material size overflow"))?; + check_plain_output(size)?; + let normalized = normalized + .to_der() .map_err(|_| KeyStoreError::Selection("invalid RSA private key"))?; - Zeroizing::new(normalized.as_bytes().to_vec()) + Zeroizing::new(normalized) }; if der.len() > resources.max_external_resource_bytes { return Err(import_resource_limit( @@ -4260,6 +4296,63 @@ mod tests { )); } + #[test] + fn plaintext_private_import_checks_all_live_copies() { + use rsa::pkcs1::EncodeRsaPrivateKey as _; + // Borrowed input, decoded PEM and retained DER coexist. Reject one + // byte below that peak without retaining a key, and accept exactly it. + let der = pem::parse(include_bytes!( + "../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("private key") + .into_contents(); + let pkcs1 = RsaPrivateKey::from_pkcs8_der(&der) + .expect("RSA") + .to_pkcs1_der() + .expect("PKCS1"); + for (label, input) in [ + ("PRIVATE KEY", der.as_slice()), + ("RSA PRIVATE KEY", pkcs1.as_bytes()), + ] { + let pem = pem::encode(&pem::Pem::new(label, input.to_vec())); + for encoded in [false, true] { + let peak = 3 + input.len() + der.len() + if encoded { pem.len() } else { 0 }; + for exact in [false, true] { + let resources = ResourcePolicy { + max_external_resource_total_bytes: peak - usize::from(!exact), + ..ResourcePolicy::default() + }; + let mut inventory = KeyInventory::default(); + let result = if encoded { + inventory.add_private_pem( + "new".into(), + pem.as_bytes(), + None, + KeyUsages::SIGN, + &resources, + ) + } else { + inventory.add_private_der( + "new".into(), + input, + None, + KeyUsages::SIGN, + &resources, + ) + }; + if exact { + result.expect("exact live-byte allowance"); + assert_eq!(inventory.entry_count(), 1); + } else { + assert!(matches!(result, Err(KeyStoreError::Policy(_)))); + assert_eq!(inventory.entry_count(), 0); + assert_eq!(inventory.material_bytes, 0); + } + } + } + } + } + #[test] fn encrypted_pkcs8_plaintext_is_reserved_before_password() { use der::Encode as _; @@ -4860,10 +4953,19 @@ mod tests { 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)); + // The first private import needs two simultaneous decoded DERs, + // in addition to encoded input; retaining the first input charge + // still makes the second padded import exceed this exact peak. + let private_workspace = if is_private { + pem::parse(pem).expect("private PEM").contents().len() * 2 + } else { + 0 + }; let resources = ResourcePolicy { max_external_resource_bytes: padded.len(), max_external_resource_total_bytes: padded.len() + if is_private { 5 } else { 10 } + + private_workspace + 1, ..ResourcePolicy::default() }; diff --git a/src/key_manager/pkcs12_import.rs b/src/key_manager/pkcs12_import.rs index 79f63a56..0d9448e0 100644 --- a/src/key_manager/pkcs12_import.rs +++ b/src/key_manager/pkcs12_import.rs @@ -792,11 +792,13 @@ struct Password<'a> { 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. + // RFC 7292 B.1 requires BMPString for both the legacy encryption + // KDF and the legacy MAC, even when encryption itself is PBES2. + // BMPString is not UTF-16: supplementary characters cannot be + // represented by surrogate pairs. PBES2-only (no legacy MAC/KDF) + // uses UTF-8 directly; RFC 9879 section 6 distinguishes PBMAC1. // https://www.rfc-editor.org/rfc/rfc7292#appendix-B.1 - // https://www.rfc-editor.org/rfc/rfc8018#section-6.2 + // https://www.rfc-editor.org/rfc/rfc9879#section-6 let mut units = 1_usize; for ch in self.utf8.chars() { if u32::from(ch) > u16::MAX as u32 { @@ -1257,6 +1259,33 @@ mod tests { } } + #[test] + fn legacy_password_format_is_bmp_not_utf16() { + // RFC 7292 B.1 is shared by legacy MAC and encryption derivation; + // surrogate-pair interoperability must not silently replace BMPString. + let limits = limits(64); + let mut budget = Budget::new(&limits); + let mut password = Password { + utf8: "p\u{ffff}", + bmp: None, + }; + assert_eq!( + password.bmp(&mut budget).expect("BMP password"), + &[0, b'p', 255, 255, 0, 0] + ); + let mut password = Password { + utf8: "secret\u{1f512}", + bmp: None, + }; + let before = budget.memory; + assert!(matches!( + password.bmp(&mut budget), + Err(KeyStoreError::ProtectedContainer) + )); + assert_eq!(budget.memory, before, "reject before allocating conversion"); + assert!(password.bmp.is_none()); + } + #[test] fn ciphertext_capacity_is_checked_before_password_derivation() { // An already-live callback buffer must stop both PBES2 and legacy KDF From 32da015431e1ca5f38d6c9a10216619bfd050280 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 2 Oct 2026 11:25:27 +0300 Subject: [PATCH 3/9] fix(xmlenc): require policy-aware recipients --- docs/xmlenc.md | 6 ++ src/xmlenc/decrypt.rs | 155 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 156 insertions(+), 5 deletions(-) diff --git a/docs/xmlenc.md b/docs/xmlenc.md index 1b00be84..738fda67 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -85,6 +85,12 @@ 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. +The trait's default policy-aware method resolves only direct content keys. It +returns `KeyNotFound` for recipient keys before invoking legacy resolution: +already-recovered AES bytes cannot establish the RSA source's policy compliance. +Custom recipient resolvers must override that method, enforce the supplied +snapshot before recovery, and charge the shared candidate budget. The built-in +RSA and AES-KW resolvers provide explicit policy-aware implementations. 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/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index 3c0bfae8..e6c011ed 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -87,6 +87,11 @@ impl KeyCandidateBudget { pub trait DecryptionKeyResolver { /// Resolve under the operation's immutable snapshot. RSA resolvers enforce /// `rsa_keys` before provider recovery; wrappers must forward this snapshot. + /// + /// The default supports direct content keys only and returns + /// [`XmlEncError::KeyNotFound`] for recipients without invoking legacy + /// resolution. Recipient resolvers must override this method: recovered + /// symmetric bytes cannot prove the original key met the operation policy. fn resolve_key_candidates_with_policy( &self, provider: &dyn crate::provider::CryptoProvider, @@ -96,7 +101,10 @@ pub trait DecryptionKeyResolver { budget: &mut KeyCandidateBudget, ) -> Result>, XmlEncError> { policy.validate()?; - self.resolve_key_candidates(provider, algorithm, encrypted_key, budget) + if encrypted_key.is_some() { + return Err(XmlEncError::KeyNotFound); + } + self.resolve_key_candidates(provider, algorithm, None, budget) } /// Resolve the symmetric key for `algorithm`, optionally unwrapping `encrypted_key`. @@ -521,6 +529,20 @@ impl KekDecryptor { } impl DecryptionKeyResolver for KekDecryptor { + 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()?; + // This resolver accepts only AES-KW and validates its fixed KEK width + // before unwrap; it cannot dispatch an RSA recovery without metadata. + self.resolve_key_candidates(provider, algorithm, encrypted_key, budget) + } + fn resolve_key( &self, provider: &dyn crate::provider::CryptoProvider, @@ -1416,6 +1438,65 @@ mod tests { keys: Vec>, } + #[test] + fn policy_unaware_recipient_resolver_is_not_dispatched() { + // An already-recovered AES key cannot prove that its RSA source met + // the operation's minimum. Reject the legacy recipient path before + // lookup, while keeping direct symmetric resolution available. + struct LegacyRecipient { + recipient_calls: Cell, + key: Vec, + } + impl DecryptionKeyResolver for LegacyRecipient { + fn resolve_key( + &self, + _provider: &dyn crate::provider::CryptoProvider, + _algorithm: DataEncryptionAlgorithm, + encrypted_key: Option<&EncryptedKey>, + ) -> Result, XmlEncError> { + if encrypted_key.is_none() { + return Err(XmlEncError::KeyNotFound); + } + self.recipient_calls.set(self.recipient_calls.get() + 1); + Ok(self.key.clone()) + } + } + let resolver = LegacyRecipient { + recipient_calls: Cell::new(0), + key: vec![0x63; 16], + }; + let encrypted = encrypted_data_with_recipients( + &resolver.key, + vec![associated_encrypted_key("recipient", None, None)], + None, + ); + assert!(matches!( + DecryptContext::new(&resolver).decrypt_data(&encrypted), + Err(XmlEncError::KeyNotFound) + )); + assert_eq!(resolver.recipient_calls.get(), 0); + + let direct = SymmetricKeyDecryptor::new(resolver.key.clone()); + let mut budget = KeyCandidateBudget::with_limit(1); + assert!(matches!( + resolver.resolve_key_candidates_with_policy( + crate::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + encrypted.encrypted_keys.first(), + &crate::policy::DecryptionPolicy::default(), + &mut budget, + ), + Err(XmlEncError::KeyNotFound) + )); + assert_eq!(budget.remaining(), 1, "no recipient work was performed"); + assert_eq!( + DecryptContext::new(&direct) + .decrypt_data(&encrypted) + .expect("direct AES does not require RSA metadata"), + DecryptedContent::Bytes(b"payload".to_vec()) + ); + } + #[test] fn document_decryption_initial_parse_uses_the_policy_work_budget() { // Candidate retries and replacement validation must inherit the same @@ -1478,6 +1559,19 @@ mod tests { } impl DecryptionKeyResolver for DirectAndRecipientResolver { + 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> { + // Prepared test keys simulate source ordering, not RSA recovery. + policy.validate()?; + self.resolve_key_candidates(provider, algorithm, encrypted_key, budget) + } + fn resolve_key( &self, _provider: &dyn crate::provider::CryptoProvider, @@ -1493,6 +1587,19 @@ mod tests { } impl DecryptionKeyResolver for FailingDirectResolver { + 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> { + // Prepared test keys simulate lookup errors, not RSA recovery. + policy.validate()?; + self.resolve_key_candidates(provider, algorithm, encrypted_key, budget) + } + fn resolve_key( &self, _provider: &dyn crate::provider::CryptoProvider, @@ -1539,13 +1646,16 @@ mod tests { Err(XmlEncError::KeyNotFound) } - fn resolve_key_candidates( + 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> { + // Prepared test keys exercise operation-wide candidate accounting. + policy.validate()?; if encrypted_key.is_none() { budget.consume(1)?; return Ok(vec![self.direct.clone()]); @@ -1565,13 +1675,16 @@ mod tests { Err(XmlEncError::KeyNotFound) } - fn resolve_key_candidates( + 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> { + // Prepared test keys simulate authenticated candidate ordering. + policy.validate()?; budget.consume(1)?; match encrypted_key.and_then(|key| key.id.as_deref()) { Some("first") => Ok(vec![self.wrong.clone()]), @@ -1617,13 +1730,16 @@ mod tests { Err(XmlEncError::KeyNotFound) } - fn resolve_key_candidates( + 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> { + // Prepared test keys isolate aggregate work from RSA primitives. + policy.validate()?; let encrypted_key = encrypted_key.ok_or(XmlEncError::KeyNotFound)?; let attempts = budget.remaining(); if attempts == 0 { @@ -1649,13 +1765,16 @@ mod tests { Err(XmlEncError::KeyNotFound) } - fn resolve_key_candidates( + 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> { + // Prepared test keys isolate XMLEnc association selection. + policy.validate()?; let encrypted_key = encrypted_key.ok_or(XmlEncError::KeyNotFound)?; budget.consume(1)?; self.visited @@ -1901,6 +2020,19 @@ mod tests { } impl DecryptionKeyResolver for CountingResolver { + 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> { + // Prepared test keys isolate validation-before-lookup ordering. + policy.validate()?; + self.resolve_key_candidates(provider, algorithm, encrypted_key, budget) + } + fn resolve_key( &self, _provider: &dyn crate::provider::CryptoProvider, @@ -1929,6 +2061,19 @@ mod tests { } impl DecryptionKeyResolver for RecipientKeyResolver { + 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> { + // Prepared test keys simulate recipient labels, not RSA recovery. + policy.validate()?; + self.resolve_key_candidates(provider, algorithm, encrypted_key, budget) + } + fn resolve_key( &self, _provider: &dyn crate::provider::CryptoProvider, From aa834fb3ac18f3dd84b82cf48ef7dc3f675ad56e Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 2 Oct 2026 12:03:37 +0300 Subject: [PATCH 4/9] fix(keys): enforce shared ingestion preflight Check borrowed RSA private components before native decoding across CLI containers. Share recipient KeyInfo parsing allowances across the operation and preserve terminal failures. --- docs/cli.md | 3 + docs/key-management.md | 5 + src/key_manager.rs | 6 +- src/xmldsig/parse.rs | 102 +++++++++++++++++++-- tools/xmlsec1/src/commands.rs | 51 ++++++++++- tools/xmlsec1/src/key_material.rs | 146 ++++++++++++++++++++++++++++-- 6 files changed, 296 insertions(+), 17 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 1c217617..df132a15 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -276,6 +276,9 @@ a different recipient. Selector-only `X509Data` (`X509SubjectName`, supplied through `--pubkey-cert-pem` or `--pubkey-cert-der`; a bare public key cannot satisfy certificate identity metadata. `KeyName` remains a lookup hint and empty `X509Data` remains a non-binding placeholder. +All nested recipient `KeyInfo` elements share the operation's embedded-key, +X.509 binary-data, and XML Base parsing allowances; each recipient does not +receive a fresh default limit. Candidate limits are checked before decoding. For `--xml-data`, a missing template `Type` is materialized as XML Element metadata so a later embedded-document decrypt can perform XML replacement. As in libxmlsec1, the input is parsed as an XML document: Element encryption diff --git a/docs/key-management.md b/docs/key-management.md index 5365aa74..7321bee2 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -61,6 +61,11 @@ 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. +RSA private-key ingestion checks borrowed PKCS#1 components before constructing +big integers in every CLI container path, including plaintext PKCS#8 and +traditional PEM after decryption. Component safety failures are terminal during +lax key search. Adapters can reuse `preflight_rsa_pkcs1_components` without +creating an inventory or decoding the native key twice. 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. diff --git a/src/key_manager.rs b/src/key_manager.rs index 11ade499..f8c2b027 100644 --- a/src/key_manager.rs +++ b/src/key_manager.rs @@ -2187,7 +2187,11 @@ fn private_key_spki(der: &[u8]) -> Result, KeyStoreError> { Err(KeyStoreError::Selection("unsupported PKCS#12 private key")) } -fn preflight_rsa_pkcs1_components(der: &[u8]) -> Result<(), KeyStoreError> { +/// Validate borrowed PKCS#1 private components against process-safety ceilings. +/// +/// Container adapters must call this before constructing native big integers. +/// This checks ingestion safety, not the operation's RSA algorithm/key policy. +pub 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(); diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index a1b41fe7..f66468dc 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -752,6 +752,47 @@ pub fn parse_key_info(key_info_node: Node) -> Result { parse_key_info_with_provider(key_info_node, crate::provider::default_provider()) } +/// Shared ingestion accounting for KeyInfo elements in one operation. +/// +/// Parsing charges embedded candidates and X.509 bytes before materialization; +/// these charges are independent of later resolver inspection work and are not +/// refunded when parsing fails. The immutable operation snapshot is borrowed. +pub struct KeyInfoParsingSession<'a> { + resources: &'a crate::policy::ResourcePolicy, + xml_base: XmlBaseResolutionBudget, + embedded_candidates: usize, + x509_binary_bytes: usize, +} + +impl<'a> KeyInfoParsingSession<'a> { + /// Start a session using the operation's resource policy. + pub fn new(resources: &'a crate::policy::ResourcePolicy) -> Result { + resources.validate()?; + Ok(Self { + resources, + xml_base: XmlBaseResolutionBudget::with_limits( + resources.effective_xml_base_components(), + resources.effective_xml_base_resolution_bytes(), + ), + embedded_candidates: 0, + x509_binary_bytes: 0, + }) + } + + /// Parse another element without resetting this operation's allowance. + pub fn parse(&mut self, node: Node) -> Result { + parse_key_info_in_session( + node, + crate::provider::default_provider(), + &self.xml_base, + self.resources, + None, + &mut self.embedded_candidates, + &mut self.x509_binary_bytes, + ) + } +} + pub(crate) fn parse_key_info_with_provider( key_info_node: Node, provider: &dyn crate::provider::CryptoProvider, @@ -786,16 +827,34 @@ pub(crate) fn parse_key_info_with_policy_budgets_and_document_base( xml_base_budget: &XmlBaseResolutionBudget, resources: &crate::policy::ResourcePolicy, document_base: Option<&str>, +) -> Result { + parse_key_info_in_session( + key_info_node, + provider, + xml_base_budget, + resources, + document_base, + &mut 0, + &mut 0, + ) +} + +fn parse_key_info_in_session( + key_info_node: Node, + provider: &dyn crate::provider::CryptoProvider, + xml_base_budget: &XmlBaseResolutionBudget, + resources: &crate::policy::ResourcePolicy, + document_base: Option<&str>, + embedded_candidate_preflight_count: &mut usize, + x509_total_binary_len: &mut usize, ) -> Result { verify_ds_element(key_info_node, "KeyInfo")?; ensure_no_non_whitespace_text(key_info_node, "KeyInfo")?; let mut sources = Vec::new(); - let mut x509_total_binary_len = 0usize; // KeyInfo is parsed before source selection, so preflight the cardinality // of every embedded key before decoding or algorithm-specific parsing. // This does not consume the resolver's inspected-candidate work budget. - let mut embedded_candidate_preflight_count = 0usize; for (index, child) in element_children(key_info_node).enumerate() { if index >= MAX_KEY_INFO_CHILD_COUNT { return Err(ParseError::InvalidStructure( @@ -810,15 +869,15 @@ pub(crate) fn parse_key_info_with_policy_budgets_and_document_base( sources.push(KeyInfoSource::KeyName(key_name)); } (Some(XMLDSIG_NS), "KeyValue") => { - charge_embedded_key_candidate(&mut embedded_candidate_preflight_count, resources)?; + charge_embedded_key_candidate(embedded_candidate_preflight_count, resources)?; let key_value = parse_key_value_dispatch(child)?; sources.push(KeyInfoSource::KeyValue(key_value)); } (Some(XMLDSIG_NS), "X509Data") => { let x509 = parse_x509_data_dispatch_with_budget_and_provider( child, - &mut x509_total_binary_len, - &mut embedded_candidate_preflight_count, + x509_total_binary_len, + embedded_candidate_preflight_count, provider, resources, )?; @@ -870,7 +929,7 @@ pub(crate) fn parse_key_info_with_policy_budgets_and_document_base( }); } (Some(XMLDSIG11_NS), "DEREncodedKeyValue") => { - charge_embedded_key_candidate(&mut embedded_candidate_preflight_count, resources)?; + charge_embedded_key_candidate(embedded_candidate_preflight_count, resources)?; ensure_no_element_children(child, "DEREncodedKeyValue")?; let der = decode_der_encoded_key_value_base64(child)?; sources.push(KeyInfoSource::DerEncodedKeyValue(der)); @@ -2649,6 +2708,37 @@ mod tests { // โ”€โ”€ parse_key_info: dispatch parsing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + #[test] + fn key_info_session_retains_failed_work_and_applies_active_policy() { + // A failed KeyValue has already consumed inspection work; a second + // parse cannot recover that allowance or use the standalone default. + let resources = crate::policy::ResourcePolicy { + max_key_candidates: 1, + ..Default::default() + }; + let xml = ""; + let document = Document::parse(xml).unwrap(); + let mut session = KeyInfoParsingSession::new(&resources).unwrap(); + assert!(matches!( + session.parse(document.root_element()), + Err(ParseError::InvalidStructure(_)) + )); + assert!(matches!( + session.parse(document.root_element()), + Err(ParseError::Policy(_)) + )); + let zero = crate::policy::ResourcePolicy { + max_key_candidates: 0, + ..resources + }; + assert!(matches!( + KeyInfoParsingSession::new(&zero) + .unwrap() + .parse(document.root_element()), + Err(ParseError::Policy(_)) + )); + } + #[test] fn key_info_candidate_budget_precedes_key_value_parsing() { // A denied embedded candidate must fail before malformed key material diff --git a/tools/xmlsec1/src/commands.rs b/tools/xmlsec1/src/commands.rs index cb4f3823..6d45ef9d 100644 --- a/tools/xmlsec1/src/commands.rs +++ b/tools/xmlsec1/src/commands.rs @@ -1702,6 +1702,7 @@ fn lax_candidate_error_is_recoverable(error: &CommandError) -> bool { | CommandError::KeyStore(key_manager::KeyStoreError::ProtectedContainer) | CommandError::KeyStore(key_manager::KeyStoreError::Policy(_)) | CommandError::Key(key_material::KeyMaterialError::ProtectedContainer) + | CommandError::Key(key_material::KeyMaterialError::PrivateKeyComponents(_)) | CommandError::Key(key_material::KeyMaterialError::Policy(_)) ) } @@ -2804,11 +2805,13 @@ fn recipient_key_metadata( )); } + let mut parsing = xml_sec::xmldsig::parse::KeyInfoParsingSession::new(&policy.resources) + .map_err(|error| CommandError::Encryption(error.to_string()))?; encrypted_keys .into_iter() .map(|encrypted_key| { direct_child_element(encrypted_key, XMLDSIG_NS, "KeyInfo") - .map(|node| parse_key_info(node).map(ParsedRecipientKeyMetadata)) + .map(|node| parsing.parse(node).map(ParsedRecipientKeyMetadata)) .transpose() .map_err(|error| CommandError::Encryption(error.to_string())) }) @@ -4258,6 +4261,47 @@ mod tests { Invocation::parse(arguments.iter().map(OsString::from)).unwrap() } + #[test] + fn recipient_metadata_shares_operation_candidate_preflight() { + // Nested recipients must not each receive a fresh policy allowance; + // the third candidate is denied before its malformed payload is decoded. + let recipient = |value: &str| { + format!( + "{value}" + ) + }; + let template = |last: &str| { + format!( + "{}{}{}", + recipient(""), + recipient(""), + recipient(last), + ) + }; + let mut policy = EncryptionPolicy::default(); + policy.resources.max_key_candidates = 2; + let error = + recipient_key_metadata(&template(""), None, &[], &policy, 3, XmlBackend::default()) + .err() + .expect("aggregate candidate limit"); + assert_eq!( + error.to_string(), + "XML encryption operation failed: XMLDSig policy violation: key candidates exceeds policy maximum 2: got 3" + ); + policy.resources.max_key_candidates = 3; + assert!( + recipient_key_metadata( + &template(""), + None, + &[], + &policy, + 3, + XmlBackend::default(), + ) + .is_ok() + ); + } + #[test] fn repeated_key_files_share_candidate_and_parser_budgets() { // The third entry must fail the operation budget before its malformed @@ -4398,6 +4442,11 @@ mod tests { assert!(!lax_candidate_error_is_recoverable(&CommandError::Key( key_material::KeyMaterialError::ProtectedContainer, ))); + assert!(!lax_candidate_error_is_recoverable(&CommandError::Key( + key_material::KeyMaterialError::PrivateKeyComponents( + key_manager::KeyStoreError::Selection("RSA modulus exceeds safety limit"), + ), + ))); assert!(!lax_candidate_error_is_recoverable( &CommandError::KeyStore(key_manager::KeyStoreError::Policy( xml_sec::policy::PolicyViolation::ResourceLimit { diff --git a/tools/xmlsec1/src/key_material.rs b/tools/xmlsec1/src/key_material.rs index d9213ea8..f64d92bd 100644 --- a/tools/xmlsec1/src/key_material.rs +++ b/tools/xmlsec1/src/key_material.rs @@ -55,6 +55,8 @@ pub enum KeyMaterialError { UnsupportedPrivateKey(PathBuf), #[error("protected key container could not be decoded")] ProtectedContainer, + #[error("private key component preflight failed: {0}")] + PrivateKeyComponents(xml_sec::key_manager::KeyStoreError), #[error("unsupported public key in {}", .0.display())] UnsupportedPublicKey(PathBuf), #[error("invalid X.509 certificate in {}", .0.display())] @@ -666,10 +668,45 @@ fn decode_traditional_rsa_pem( path: &Path, ) -> Result { let der = decode_openssl_traditional_pem(text, "RSA PRIVATE KEY", password, path)?; + preflight_rsa_der(&der.der, false, path).map_err(|error| { + if der.encrypted && !matches!(error, KeyMaterialError::PrivateKeyComponents(_)) { + KeyMaterialError::ProtectedContainer + } else { + error + } + })?; RsaPrivateKey::from_pkcs1_der(&der.der) .map_err(|_| traditional_key_decode_error(Some(&der), path)) } +fn preflight_rsa_der(bytes: &[u8], pkcs8_only: bool, path: &Path) -> Result<(), KeyMaterialError> { + let components = match PrivateKeyInfoRef::try_from(bytes) { + Ok(info) if info.algorithm.oid == rsa::pkcs1::ALGORITHM_OID => info.private_key.as_bytes(), + Ok(_) => return Err(KeyMaterialError::UnsupportedPrivateKey(path.to_owned())), + Err(_) if !pkcs8_only => bytes, + Err(_) => return Err(KeyMaterialError::UnsupportedPrivateKey(path.to_owned())), + }; + xml_sec::key_manager::preflight_rsa_pkcs1_components(components).map_err(|error| match error { + xml_sec::key_manager::KeyStoreError::Selection("invalid RSA private key") => { + KeyMaterialError::UnsupportedPrivateKey(path.to_owned()) + } + error => KeyMaterialError::PrivateKeyComponents(error), + }) +} + +fn decode_plain_rsa_pkcs8_pem( + bytes: &[u8], + path: &Path, +) -> Result>, KeyMaterialError> { + let (label, der) = + der::pem::decode_vec(bytes).map_err(|_| KeyMaterialError::InvalidPem(path.to_owned()))?; + let der = Zeroizing::new(der); + if label != "PRIVATE KEY" { + return Err(KeyMaterialError::UnsupportedPrivateKey(path.to_owned())); + } + Ok(der) +} + struct TraditionalPemKey { der: Zeroizing>, encrypted: bool, @@ -989,23 +1026,30 @@ pub fn decode_rsa_private_with_password( .and_then(|entry| RsaPrivateKey::from_pkcs8_der(&entry.pkcs8_der).ok()) .ok_or_else(|| KeyMaterialError::UnsupportedPrivateKey(path.to_owned())); } - match format { + let pem_der; + let der = match format { 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() + pem_der = decode_plain_rsa_pkcs8_pem(text.as_bytes(), path)?; + pem_der.as_slice() } else { return decode_traditional_rsa_pem(text, password, path); } } - PrivateKeyFormat::Der => RsaPrivateKey::from_pkcs8_der(bytes) - .or_else(|_| RsaPrivateKey::from_pkcs1_der(bytes)) - .ok(), - PrivateKeyFormat::Pkcs8Pem => std::str::from_utf8(bytes) - .ok() - .and_then(|text| RsaPrivateKey::from_pkcs8_pem(text).ok()), - PrivateKeyFormat::Pkcs8Der => RsaPrivateKey::from_pkcs8_der(bytes).ok(), + PrivateKeyFormat::Pkcs8Pem => { + pem_der = decode_plain_rsa_pkcs8_pem(bytes, path)?; + pem_der.as_slice() + } + PrivateKeyFormat::Der | PrivateKeyFormat::Pkcs8Der => bytes, + }; + let pkcs8_only = format != PrivateKeyFormat::Der; + preflight_rsa_der(der, pkcs8_only, path)?; + if PrivateKeyInfoRef::try_from(der).is_ok() { + RsaPrivateKey::from_pkcs8_der(der).ok() + } else { + RsaPrivateKey::from_pkcs1_der(der).ok() } .ok_or_else(|| KeyMaterialError::UnsupportedPrivateKey(path.to_owned())) } @@ -1115,6 +1159,90 @@ mod tests { use super::*; + #[test] + fn rsa_containers_preflight_components_before_native_decode() { + // Every CLI container must reject excessive borrowed components before + // bigint allocation, including traditional PEM after decryption. + let pem = include_str!("../../../tests/fixtures/keys/rsa/rsa-2048-key.pem"); + let (_, der) = der::pem::decode_vec(pem.as_bytes()).expect("fixture PEM"); + let info = PrivateKeyInfoRef::try_from(der.as_slice()).expect("fixture PKCS#8"); + let oversized = vec![1_u8; 1025]; + for modulus in [true, false] { + let mut key = rsa::pkcs1::RsaPrivateKey::from_der(info.private_key.as_bytes()).unwrap(); + if modulus { + key.modulus = UintRef::new(&oversized).unwrap(); + } else { + key.private_exponent = UintRef::new(&oversized).unwrap(); + } + let pkcs1 = key.to_der().unwrap(); + let pkcs8 = PrivateKeyInfoRef::new( + rsa::pkcs1::ALGORITHM_ID, + der::asn1::OctetStringRef::new(&pkcs1).unwrap(), + ) + .to_der() + .unwrap(); + let plain_pem = pem::encode(&pem::Pem::new("PRIVATE KEY", pkcs8.clone())); + let traditional = pem::encode(&pem::Pem::new("RSA PRIVATE KEY", pkcs1.clone())); + let protected = encrypted_traditional_pem("RSA PRIVATE KEY", &pkcs1, b"secret"); + for (bytes, format, password) in [ + (pkcs1.as_slice(), PrivateKeyFormat::Der, None), + (pkcs8.as_slice(), PrivateKeyFormat::Der, None), + (pkcs8.as_slice(), PrivateKeyFormat::Pkcs8Der, None), + (plain_pem.as_bytes(), PrivateKeyFormat::Pem, None), + (plain_pem.as_bytes(), PrivateKeyFormat::Pkcs8Pem, None), + (traditional.as_bytes(), PrivateKeyFormat::Pem, None), + ( + protected.as_bytes(), + PrivateKeyFormat::Pem, + Some(b"secret".as_slice()), + ), + ] { + let error = decode_rsa_private_with_password( + Path::new("key"), + bytes, + format, + password, + &ResourcePolicy::default(), + ) + .expect_err("oversized components must fail preflight"); + assert!( + error.to_string().contains("safety limit"), + "{format:?}: {error}" + ); + } + } + } + + #[test] + fn rsa_pkcs8_pem_keeps_label_and_protected_failure_contracts() { + // A borrowed preflight must not enable label fallback or downgrade + // unauthenticated CBC plaintext to an ordinary candidate mismatch. + let fixture = include_bytes!("../../../tests/fixtures/keys/rsa/rsa-2048-key.pem"); + let (_, der) = der::pem::decode_vec(fixture).unwrap(); + let mislabeled = pem::encode(&pem::Pem::new("CERTIFICATE", der)); + assert!( + decode_rsa_private_with_password( + Path::new("key.pem"), + mislabeled.as_bytes(), + PrivateKeyFormat::Pkcs8Pem, + None, + &ResourcePolicy::default(), + ) + .is_err() + ); + let protected = encrypted_traditional_pem("RSA PRIVATE KEY", b"not ASN.1", b"secret"); + assert!(matches!( + decode_rsa_private_with_password( + Path::new("key.pem"), + protected.as_bytes(), + PrivateKeyFormat::Pem, + Some(b"secret"), + &ResourcePolicy::default(), + ), + Err(KeyMaterialError::ProtectedContainer) + )); + } + #[test] fn protected_rsa_container_failure_is_not_a_lax_candidate_miss() { // A wrong or missing password must stop lax search before a later From 1e42d1919b1111e2376533976345d75bf20699bb Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 2 Oct 2026 12:39:16 +0300 Subject: [PATCH 5/9] fix(keys): enforce import and resolver bounds Recheck direct AES permission against the execution snapshot before copying key material. Compare imported private/public identities without serializing temporary SPKI buffers or repeating RSA decoding. Clarify public-only RSA XML stores and their supported private-key input alternatives. --- docs/cli.md | 6 +- docs/key-management.md | 4 +- src/key_manager.rs | 269 +++++++++++++++++++++--- tools/xmlsec1/src/commands.rs | 5 +- tools/xmlsec1/tests/process_contract.rs | 7 + 5 files changed, 257 insertions(+), 34 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index df132a15..4d64306d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -187,9 +187,11 @@ 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 +verification keys, direct AES content keys, and RSA-OAEP encryption 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 +operation policy. XML `RSAKeyValue` entries are public-only and cannot recover +an encrypted recipient key; use `--privkey-pem`, `--privkey-der`, or `--pkcs12` +for RSA decryption. `--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 diff --git a/docs/key-management.md b/docs/key-management.md index 7321bee2..b34eb299 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -22,7 +22,9 @@ 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 +HMAC, AES, public RSA/EC, and libxmlsec1's private DSA extension. RSA private +keys are imported through the PEM/DER or PKCS#12 APIs, not `RSAKeyValue`. +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 diff --git a/src/key_manager.rs b/src/key_manager.rs index f8c2b027..feef6eec 100644 --- a/src/key_manager.rs +++ b/src/key_manager.rs @@ -685,6 +685,27 @@ impl crate::xmlenc::DecryptionKeyResolver for InventoryDirectAes { self.resolve_key(provider, algorithm, None) .map(|key| vec![key]) } + + fn resolve_key_candidates_with_policy( + &self, + provider: &dyn crate::provider::CryptoProvider, + algorithm: crate::xmlenc::DataEncryptionAlgorithm, + encrypted_key: Option<&crate::xmlenc::EncryptedKey>, + policy: &crate::policy::DecryptionPolicy, + budget: &mut crate::xmlenc::KeyCandidateBudget, + ) -> Result>, crate::xmlenc::XmlEncError> { + policy.validate()?; + if encrypted_key.is_some() { + return Err(crate::xmlenc::XmlEncError::KeyNotFound); + } + check_selected_material_size(self.0.len(), &policy.resources).map_err( + |error| match error { + KeyStoreError::Policy(violation) => crate::xmlenc::XmlEncError::Policy(violation), + other => crate::xmlenc::XmlEncError::InvalidStructure(other.to_string()), + }, + )?; + self.resolve_key_candidates(provider, algorithm, None, budget) + } } #[derive(Debug, thiserror::Error)] @@ -1362,12 +1383,13 @@ impl KeyInventory { resources.max_external_resource_bytes, )); } - private_key_spki(&der)?; - if usages.allows(KeyUsage::Decrypt) && RsaPrivateKey::from_pkcs8_der(&der).is_err() { + let identity = PrivateKeyIdentity::decode(&der)?; + if usages.allows(KeyUsage::Decrypt) && !matches!(identity, PrivateKeyIdentity::Rsa(_)) { return Err(KeyStoreError::Selection( "only RSA private keys can be used for decryption", )); } + drop(identity); self.reserve_material( named_material_length(&name, bytes.len().max(der.len()), 1)?, resources, @@ -1584,10 +1606,8 @@ impl KeyInventory { .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() - { + let identity = PrivateKeyIdentity::decode(private_key.as_ref())?; + if usages.allows(KeyUsage::Decrypt) && !matches!(identity, PrivateKeyIdentity::Rsa(_)) { if auto_decrypt { usages = KeyUsages::SIGN; } else { @@ -1603,7 +1623,7 @@ impl KeyInventory { if !rest.is_empty() { return Err(KeyStoreError::Selection("invalid certificate in PKCS#12")); } - if parsed.public_key().raw == spki.as_slice() { + if identity.matches_spki(parsed.public_key().raw) { if matching_leaf.is_some_and(|leaf: &[u8]| leaf != certificate.as_slice()) { return Err(KeyStoreError::Selection( "ambiguous certificate for PKCS#12 private key", @@ -1614,6 +1634,7 @@ impl KeyInventory { matching_leaf = Some(certificate.as_slice()); } } + drop(identity); // 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(); @@ -2160,31 +2181,105 @@ fn named_material_length( .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")); - } +// Public identity stays borrowed (RSA), native (DSA), or stack-sized (EC). +// Import validation must not serialize an unaccounted SPKI beside live input. +enum PrivateKeyIdentity<'a> { + Rsa(rsa::pkcs1::RsaPrivateKey<'a>), + Dsa { + algorithm: rsa::pkcs8::AlgorithmIdentifierRef<'a>, + key: NativeDsaSigningKey, + }, + Ec { + algorithm: rsa::pkcs8::AlgorithmIdentifierRef<'a>, + point: [u8; 133], + length: usize, + }, +} + +impl<'a> PrivateKeyIdentity<'a> { + fn decode(der: &'a [u8]) -> Result { + 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())?; + RsaPrivateKey::from_pkcs8_der(der) + .map_err(|_| KeyStoreError::Selection("invalid RSA private key"))?; + return rsa::pkcs1::RsaPrivateKey::from_der(info.private_key.as_bytes()) + .map(Self::Rsa) + .map_err(|_| KeyStoreError::Selection("invalid RSA private key")); + } else if info.algorithm.oid == dsa::OID { + preflight_dsa_pkcs8_components(&info)?; + return NativeDsaSigningKey::from_pkcs8_der(der) + .map(|key| Self::Dsa { + algorithm: info.algorithm, + key, + }) + .map_err(|_| KeyStoreError::Selection("invalid DSA private key")); + } + macro_rules! try_ec { + ($key:ty) => { + if let Ok(key) = <$key>::from_pkcs8_der(der) { + use p256::elliptic_curve::sec1::ToSec1Point as _; + let encoded = key.public_key().to_sec1_point(false); + let bytes = encoded.as_bytes(); + let mut point = [0; 133]; + point[..bytes.len()].copy_from_slice(bytes); + return Ok(Self::Ec { + algorithm: info.algorithm, + point, + length: bytes.len(), + }); + } + }; + } + try_ec!(p256::SecretKey); + try_ec!(p384::SecretKey); + try_ec!(p521::SecretKey); + Err(KeyStoreError::Selection("unsupported PKCS#12 private key")) + } + + fn matches_spki(&self, der: &[u8]) -> bool { + let Ok(spki) = rsa::pkcs8::SubjectPublicKeyInfoRef::from_der(der) else { + return false; + }; + let Some(bytes) = spki.subject_public_key.as_bytes() else { + return false; }; + match self { + Self::Rsa(key) => { + if spki.algorithm != rsa::pkcs1::ALGORITHM_ID { + return false; + } + let Ok(public) = rsa::pkcs1::RsaPublicKey::from_der(bytes) else { + return false; + }; + key.modulus == public.modulus && key.public_exponent == public.public_exponent + } + Self::Dsa { algorithm, key } => { + if spki.algorithm != *algorithm { + return false; + } + let Ok(y) = der::asn1::UintRef::from_der(bytes) else { + return false; + }; + // Compare native little-endian limbs as a byte iterator, without + // materializing a second big integer or owned public encoding. + key.verifying_key() + .y() + .as_words() + .iter() + .rev() + .flat_map(|word| word.to_be_bytes()) + .skip_while(|byte| *byte == 0) + .eq(y.as_bytes().iter().copied()) + } + Self::Ec { + algorithm, + point, + length, + } => spki.algorithm == *algorithm && bytes == &point[..*length], + } } - try_key!(RsaSigningKey); - try_key!(DsaSigningKey); - try_key!(EcdsaP256SigningKey); - try_key!(EcdsaP384SigningKey); - try_key!(EcdsaP521SigningKey); - Err(KeyStoreError::Selection("unsupported PKCS#12 private key")) } /// Validate borrowed PKCS#1 private components against process-safety ceilings. @@ -4300,6 +4395,71 @@ mod tests { )); } + #[test] + fn private_identity_matches_without_owned_public_encoding() { + // Compare every supported family against its canonical encoder, and + // reject changed public material. Import itself retains no SPKI copy. + let rsa_der = pem::parse(include_bytes!( + "../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("RSA PEM fixture") + .into_contents(); + let rsa = RsaPrivateKey::from_pkcs8_der(&rsa_der).expect("RSA private key"); + let public = rsa + .to_public_key() + .to_public_key_der() + .expect("RSA public encoding"); + let identity = PrivateKeyIdentity::decode(&rsa_der).expect("RSA identity"); + assert!(identity.matches_spki(public.as_bytes())); + let mut changed = public.as_bytes().to_vec(); + *changed.last_mut().expect("nonempty RSA SPKI") ^= 2; + assert!(!identity.matches_spki(&changed)); + assert!(!identity.matches_spki(b"invalid DER")); + + macro_rules! check_ec { + ($key:ty, $length:expr) => {{ + let mut scalar = [0; $length]; + scalar[$length - 1] = 1; + let key = <$key>::from_slice(&scalar).expect("valid EC scalar"); + let private = key.to_pkcs8_der().expect("EC private encoding"); + let public = key + .public_key() + .to_public_key_der() + .expect("EC public encoding"); + let identity = PrivateKeyIdentity::decode(private.as_bytes()).expect("EC identity"); + assert!(identity.matches_spki(public.as_bytes())); + let mut changed = public.as_bytes().to_vec(); + *changed.last_mut().expect("nonempty EC SPKI") ^= 1; + assert!(!identity.matches_spki(&changed)); + }}; + } + check_ec!(p256::SecretKey, 32); + check_ec!(p384::SecretKey, 48); + check_ec!(p521::SecretKey, 66); + + let inventory = KeyInventory::from_xml_bytes( + include_bytes!("../tests/fixtures/keys/xmlsec/mixed-keys.xml"), + &xml_policy(ResourcePolicy::default()), + XmlBackend::default(), + ) + .expect("mixed store fixture"); + let private = inventory + .private_keys() + .iter() + .find(|entry| entry.name == "test-dsa") + .expect("stored DSA private key"); + let key = NativeDsaSigningKey::from_pkcs8_der(&private.pkcs8_der).expect("DSA private key"); + let public = key + .verifying_key() + .to_public_key_der() + .expect("DSA public encoding"); + let identity = PrivateKeyIdentity::decode(&private.pkcs8_der).expect("DSA identity"); + assert!(identity.matches_spki(public.as_bytes())); + let mut changed = public.as_bytes().to_vec(); + *changed.last_mut().expect("nonempty DSA SPKI") ^= 1; + assert!(!identity.matches_spki(&changed)); + } + #[test] fn plaintext_private_import_checks_all_live_copies() { use rsa::pkcs1::EncodeRsaPrivateKey as _; @@ -6403,6 +6563,55 @@ mod tests { ); } + #[cfg(feature = "xmlenc")] + #[test] + fn inventory_direct_aes_rechecks_execution_policy() { + // Selection does not freeze resource permission: reject a tighter + // execution snapshot before copying bytes or consuming a candidate. + use crate::xmlenc::{DataEncryptionAlgorithm, KeyCandidateBudget, XmlEncError}; + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "aes".into(), + SymmetricKeyKind::Aes, + vec![1; 16], + KeyUsages::DECRYPT, + &ResourcePolicy::default(), + ) + .expect("AES imports"); + let resolver = inventory + .decryption_resolver("aes", &crate::policy::DecryptionPolicy::default()) + .expect("AES resolver"); + let mut policy = crate::policy::DecryptionPolicy::default(); + policy.resources.max_external_resource_bytes = 15; + let mut budget = KeyCandidateBudget::with_limit(1); + assert!(matches!( + resolver.resolve_key_candidates_with_policy( + &crate::provider::RustCryptoProvider, + DataEncryptionAlgorithm::Aes128Gcm, + None, + &policy, + &mut budget + ), + Err(XmlEncError::Policy(_)) + )); + assert_eq!(budget.remaining(), 1); + policy.resources.max_external_resource_bytes = 16; + assert_eq!( + resolver + .resolve_key_candidates_with_policy( + &crate::provider::RustCryptoProvider, + DataEncryptionAlgorithm::Aes128Gcm, + None, + &policy, + &mut budget + ) + .expect("exact execution allowance"), + vec![vec![1; 16]] + ); + assert_eq!(budget.remaining(), 0); + } + #[cfg(feature = "xmlenc")] #[test] fn inventory_direct_aes_does_not_consume_recipient_candidates() { diff --git a/tools/xmlsec1/src/commands.rs b/tools/xmlsec1/src/commands.rs index 6d45ef9d..d12a0c5f 100644 --- a/tools/xmlsec1/src/commands.rs +++ b/tools/xmlsec1/src/commands.rs @@ -3509,8 +3509,11 @@ fn decrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman && entry.usages.allows(key_manager::KeyUsage::Decrypt) }) { + // XMLDSig 1.1 section 4.5.2.2 defines RSAKeyValue as Modulus and + // Exponent, not a private-key container: + // https://www.w3.org/TR/2013/REC-xmldsig-core1-20130411/#sec-RSAKeyValue return Err(CommandError::Usage( - "--keys-file does not supply RSA recipient private keys for decrypt".into(), + "--keys-file does not supply RSA recipient private keys for decrypt: RSAKeyValue imports are public-only; use --privkey-pem, --privkey-der, or --pkcs12".into(), )); } let requested_names = content_key_name.iter().cloned().collect::>(); diff --git a/tools/xmlsec1/tests/process_contract.rs b/tools/xmlsec1/tests/process_contract.rs index 8a5a17d2..f69522e5 100644 --- a/tools/xmlsec1/tests/process_contract.rs +++ b/tools/xmlsec1/tests/process_contract.rs @@ -3746,6 +3746,13 @@ fn key_store_rsa_recipient_round_trips_with_explicit_private_key() { String::from_utf8_lossy(&unsupported_store_decrypt.stderr) .contains("--keys-file does not supply RSA recipient private keys") ); + // Public-key store rejection identifies the unavailable private material + // and the applicable input options, rather than implying failed recovery. + assert!( + String::from_utf8_lossy(&unsupported_store_decrypt.stderr).contains( + "RSAKeyValue imports are public-only; use --privkey-pem, --privkey-der, or --pkcs12" + ) + ); let wrong_public_pem = fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem")) From fd0e820ff4a47ece06860cb27a8648767e1138b4 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 2 Oct 2026 13:47:44 +0300 Subject: [PATCH 6/9] fix(keys): bound public PEM import workspace Preflight simultaneous encoded, decoded, and normalized buffers before allocating. Stream borrowed PEM payloads and wrap RSA octets without intermediate key serialization. Preserve encryption feature gates in tests. --- docs/key-management.md | 3 + src/key_manager.rs | 363 ++++++++++++++++++++++++++++++++----- tests/capability_ledger.rs | 1 + 3 files changed, 322 insertions(+), 45 deletions(-) diff --git a/docs/key-management.md b/docs/key-management.md index b34eb299..781f4b96 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -104,6 +104,9 @@ permits reinterpretation, but this API deliberately forbids it to preserve 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. +Public PEM imports preflight retained inventory plus simultaneously live encoded +input and decoded DER. PKCS#1 RSA imports also preflight the final SPKI output +before wrapping borrowed key octets; decoding needs no normalized Base64 string. Oversized encoded bundles return a typed resource-policy error without invoking the callback. `ResourcePolicy::max_key_import_kdf_work` and diff --git a/src/key_manager.rs b/src/key_manager.rs index feef6eec..ec919e97 100644 --- a/src/key_manager.rs +++ b/src/key_manager.rs @@ -14,15 +14,12 @@ use dsa::{ }; mod pkcs12_import; use pkcs12_import::Limits as Pkcs12Limits; -#[cfg(feature = "xmlenc")] -use rsa::pkcs8::DecodePublicKey as _; use rsa::{ - RsaPrivateKey, RsaPublicKey, - pkcs1::DecodeRsaPublicKey as _, - pkcs8::{ - DecodePrivateKey as _, EncodePublicKey as _, EncryptedPrivateKeyInfoRef, PrivateKeyInfoRef, - }, + RsaPrivateKey, + pkcs8::{DecodePrivateKey as _, EncryptedPrivateKeyInfoRef, PrivateKeyInfoRef}, }; +#[cfg(feature = "xmlenc")] +use rsa::{RsaPublicKey, pkcs8::DecodePublicKey as _}; use x509_parser::prelude::{FromDer as _, X509Certificate}; use zeroize::Zeroizing; @@ -1205,6 +1202,8 @@ impl KeyInventory { /// Import one PEM-encoded public key. RFC 7468 labels select SPKI or /// PKCS#1; extra text and multiple armor blocks are rejected. + /// Aggregate capacity includes retained inventory, the live encoded input, + /// decoded DER, and any SPKI normalization output before allocation. pub fn add_public_pem( &mut self, name: String, @@ -1235,22 +1234,62 @@ impl KeyInventory { 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(), + let block = BorrowedPublicPem::parse(bytes, resources.max_external_resource_bytes)?; + self.check_material_capacity( + named_material_length( + &name, + bytes + .len() + .checked_add(block.decoded_len) + .ok_or(KeyStoreError::Selection("key material size overflow"))?, + 2, + )?, + resources, + )?; + let decoded = block.decode()?; + let der = match block.label { + "PUBLIC KEY" => decoded, "RSA PUBLIC KEY" => { - let components = rsa::pkcs1::RsaPublicKey::from_der(block.contents()) + use der::Encode as _; + let components = rsa::pkcs1::RsaPublicKey::from_der(&decoded) .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"))? + // Wrap borrowed PKCS#1 bytes directly; the final SPKI importer + // performs native RSA validation once, without an intermediate key. + let spki = rsa::pkcs8::SubjectPublicKeyInfoRef { + algorithm: rsa::pkcs1::ALGORITHM_ID, + subject_public_key: der::asn1::BitStringRef::new(0, &decoded) + .map_err(|_| KeyStoreError::Selection("invalid RSA public key"))?, + }; + let encoded_len = usize::try_from( + spki.encoded_len() + .map_err(|_| KeyStoreError::Selection("invalid RSA public key"))?, + ) + .map_err(|_| KeyStoreError::Selection("invalid RSA public key"))?; + if encoded_len > resources.max_external_resource_bytes { + 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() + .checked_add(decoded.capacity()) + .and_then(|total| total.checked_add(encoded_len)) + .ok_or(KeyStoreError::Selection("key material size overflow"))?, + 2, + )?, + resources, + )?; + spki.to_der() + .map_err(|_| KeyStoreError::Selection("invalid RSA public key"))? } _ => return Err(KeyStoreError::Selection("unsupported public PEM label")), }; @@ -2354,6 +2393,135 @@ fn preflight_dsa_pkcs8_components(info: &PrivateKeyInfoRef<'_>) -> Result<(), Ke Ok(()) } +// Borrow the frame and stream whitespace-separated Base64 into one preflighted +// DER buffer. No normalized Base64 string or owned label/header copies are needed. +struct BorrowedPublicPem<'a> { + label: &'a str, + data: &'a str, + decoded_len: usize, +} + +impl<'a> BorrowedPublicPem<'a> { + fn parse(bytes: &'a [u8], maximum: usize) -> Result { + if bytes.len() > maximum { + return Err(import_resource_limit( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum, + )); + } + let invalid = || KeyStoreError::Selection("invalid PEM key"); + let text = std::str::from_utf8(bytes) + .map_err(|_| invalid())? + .trim_matches(|character: char| character.is_ascii_whitespace()); + let (label, rest) = text + .strip_prefix("-----BEGIN ") + .and_then(|rest| rest.split_once("-----")) + .ok_or_else(invalid)?; + if !matches!(label, "PUBLIC KEY" | "RSA PUBLIC KEY") { + return Err(KeyStoreError::Selection("unsupported public PEM label")); + } + let (payload, end) = rest + .trim_start_matches([' ', '\t', '\n', '\r']) + .split_once("-----END ") + .ok_or_else(invalid)?; + let begin = if label == "PUBLIC KEY" { + "-----BEGIN PUBLIC KEY-----" + } else { + "-----BEGIN RSA PUBLIC KEY-----" + }; + if end.strip_prefix(label) != Some("-----") || payload.contains(begin) { + return Err(KeyStoreError::Selection( + "PEM must contain one complete block", + )); + } + // Preserve the existing importer's optional header and whitespace + // acceptance without allocating header strings or a stripped body. + let data = if let Some((headers, data)) = payload + .split_once("\n\n") + .or_else(|| payload.split_once("\r\n\r\n")) + { + if headers.lines().any(|line| !line.contains(':')) { + return Err(invalid()); + } + data + } else { + payload + }; + let mut characters = 0_usize; + let mut padding = 0_usize; + for part in data.split_whitespace() { + for byte in part.bytes() { + if byte == b'=' { + padding += 1; + } else if padding != 0 + || !matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/') + { + return Err(invalid()); + } + characters += 1; + } + } + if !characters.is_multiple_of(4) || padding > 2 { + return Err(invalid()); + } + let decoded_len = (characters / 4 * 3) + .checked_sub(padding) + .ok_or_else(invalid)?; + Ok(Self { + label, + data, + decoded_len, + }) + } + + fn decode(&self) -> Result, KeyStoreError> { + use std::io::Read as _; + let input = PemBase64Reader { + parts: self.data.split_whitespace(), + current: &[], + }; + let mut decoder = + base64::read::DecoderReader::new(input, &base64::engine::general_purpose::STANDARD); + let mut decoded = vec![0; self.decoded_len]; + decoder + .read_exact(&mut decoded) + .map_err(|_| KeyStoreError::Selection("invalid PEM key"))?; + let mut eof = [0]; + if decoder + .read(&mut eof) + .map_err(|_| KeyStoreError::Selection("invalid PEM key"))? + != 0 + { + return Err(KeyStoreError::Selection("invalid PEM key")); + } + Ok(decoded) + } +} + +struct PemBase64Reader<'a> { + parts: std::str::SplitWhitespace<'a>, + current: &'a [u8], +} + +impl std::io::Read for PemBase64Reader<'_> { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let mut written = 0; + while written < buffer.len() { + if self.current.is_empty() { + let Some(part) = self.parts.next() else { + break; + }; + self.current = part.as_bytes(); + } + let length = self.current.len().min(buffer.len() - written); + buffer[written..written + length].copy_from_slice(&self.current[..length]); + self.current = &self.current[length..]; + written += length; + } + Ok(written) + } +} + fn single_pem_block(bytes: &[u8], maximum: usize) -> Result { if bytes.len() > maximum { return Err(import_resource_limit( @@ -2700,7 +2868,9 @@ fn parse_xmlsec_dsa_key_value(node: Node<'_, '_>) -> Result Date: Fri, 2 Oct 2026 14:45:30 +0300 Subject: [PATCH 7/9] fix(keys): preflight decoded import buffers --- docs/key-management.md | 11 +- src/key_manager.rs | 445 ++++++++++++++++++++++++++++++-------- src/xmldsig/parse.rs | 232 +++++++++++++------- src/xmldsig/whitespace.rs | 157 ++++++++++++++ 4 files changed, 672 insertions(+), 173 deletions(-) diff --git a/docs/key-management.md b/docs/key-management.md index 781f4b96..ccb818f9 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -104,8 +104,9 @@ permits reinterpretation, but this API deliberately forbids it to preserve 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. -Public PEM imports preflight retained inventory plus simultaneously live encoded -input and decoded DER. PKCS#1 RSA imports also preflight the final SPKI output +Public and private PEM imports preflight retained inventory plus simultaneously +live encoded input and decoded DER before allocating the decoded buffer. +PKCS#1 RSA public imports also preflight the final SPKI output before wrapping borrowed key octets; decoding needs no normalized Base64 string. Oversized encoded bundles return a typed resource-policy error without invoking the callback. @@ -161,6 +162,12 @@ uses this session for repeated `--keys-file`: candidate inspections and XML pars 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. +XML imports reserve the source together with decoded components, cloned names, +and private-key encoding workspace, rather than using the larger of source and +retained material. XML Base64 decoding borrows text nodes and uses one exact-size +output buffer without normalized text copies. Embedded X509Data binary values +obey the active per-resource and aggregate byte limits before decoding; repeated +KeyInfo parses in one session retain that charge, including failed candidates. 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 ec919e97..f3a31f1f 100644 --- a/src/key_manager.rs +++ b/src/key_manager.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; +#[cfg(test)] use base64::Engine as _; use crypto_bigint::{ BoxedUint, @@ -37,7 +38,7 @@ use crate::{ DefaultKeyResolver, DsaSigningKey, DsigError, EcdsaP256SigningKey, EcdsaP384SigningKey, EcdsaP521SigningKey, HmacSigningKey, HmacVerificationKey, KeyInfo, KeyInfoSource, KeyResolver, KeyResolverConfig, KeyValueInfo, RsaSigningKey, SignatureAlgorithm, - SigningKey, VerifyingKey, X509DataInfo, parse_key_info, validate_signing_key, + SigningKey, VerifyingKey, X509DataInfo, validate_signing_key, }, }; @@ -641,7 +642,7 @@ impl<'a> KeyResolver for InventoryVerificationResolver<'a> { enum ParsedMaterial { Symmetric(SymmetricKeyKind, Zeroizing>), - Public(Option), + Public(KeyValueInfo), Dsa(KeyValueInfo, Option>>), Unsupported, } @@ -1234,7 +1235,10 @@ impl KeyInventory { ensure_resource_policy(resources)?; self.check_new_name(&name, resources)?; self.check_material_capacity(named_material_length(&name, bytes.len(), 2)?, resources)?; - let block = BorrowedPublicPem::parse(bytes, resources.max_external_resource_bytes)?; + let block = BorrowedPem::parse(bytes, resources.max_external_resource_bytes)?; + if !matches!(block.label, "PUBLIC KEY" | "RSA PUBLIC KEY") { + return Err(KeyStoreError::Selection("unsupported public PEM label")); + } self.check_material_capacity( named_material_length( &name, @@ -1521,20 +1525,32 @@ impl KeyInventory { 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)?; + let block = BorrowedPem::parse(bytes, resources.max_external_resource_bytes)?; + validate_private_key_usages(usages)?; + self.check_material_capacity( + named_material_length( + &name, + bytes + .len() + .checked_add(block.decoded_len) + .ok_or(KeyStoreError::Selection("key material size overflow"))?, + 1, + )?, + resources, + )?; enum Payload { Plain, Encrypted, Rsa, - Unsupported, } - let payload = match block.tag() { + let payload = match block.label { "PRIVATE KEY" => Payload::Plain, "ENCRYPTED PRIVATE KEY" => Payload::Encrypted, "RSA PRIVATE KEY" => Payload::Rsa, - _ => Payload::Unsupported, + _ => return Err(KeyStoreError::Selection("unsupported private PEM label")), }; - let der = Zeroizing::new(block.into_contents()); + let mut der = Zeroizing::new(vec![0; block.decoded_len]); + block.decode_into(&mut der)?; // 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 @@ -1548,9 +1564,6 @@ impl KeyInventory { .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")); - } } let previous_total = self.material_bytes; let name_len = name.len(); @@ -1926,6 +1939,10 @@ impl KeyInventory { if !root.has_tag_name((XMLSEC_NS, "Keys")) { return Err(KeyStoreError::Invalid("expected xmlsec Keys root".into())); } + let mut material_budget = XmlImportMaterialBudget { + used: live_material + bytes.len(), + maximum: resources.max_external_resource_total_bytes, + }; let mut names = HashSet::new(); let mut store = Self::default(); let mut entry_count = 0_usize; @@ -1941,6 +1958,7 @@ impl KeyInventory { } *inspected += 1; entry_count += 1; + preflight_xml_material(info, &mut material_budget)?; let mut name = None; let mut value = None; for child in info.children().filter(|child| child.is_element()) { @@ -1974,16 +1992,20 @@ impl KeyInventory { None }; value = Some(if let Some(kind) = material { - ParsedMaterial::Symmetric(kind, Zeroizing::new(decode_xml_base64(key)?)) + ParsedMaterial::Symmetric(kind, decode_xml_base64_secret(key)?) } else if key.has_tag_name((XMLDSIG_NS, "DSAKeyValue")) { - let (public, private) = parse_xmlsec_dsa_key_value(key)?; + let (public, private) = + parse_xmlsec_dsa_key_value(key, &mut material_budget)?; 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) + ParsedMaterial::Public( + crate::xmldsig::parse::parse_key_value_dispatch(child) + .map_err(|error| KeyStoreError::Invalid(error.to_string()))?, + ) } else { ParsedMaterial::Unsupported }); @@ -2019,16 +2041,10 @@ impl KeyInventory { 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()))? - }; + ParsedMaterial::Public(value) => { + let mut key_info = KeyInfo::default(); + key_info.sources.push(KeyInfoSource::KeyName(name.clone())); + key_info.sources.push(KeyInfoSource::KeyValue(value)); let is_rsa = key_info .sources .iter() @@ -2079,7 +2095,10 @@ impl KeyInventory { 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()?); + store.material_bytes = bytes + .len() + .checked_add(store.retained_material_bytes()?) + .ok_or(KeyStoreError::Selection("key material size overflow"))?; 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, @@ -2395,13 +2414,13 @@ fn preflight_dsa_pkcs8_components(info: &PrivateKeyInfoRef<'_>) -> Result<(), Ke // Borrow the frame and stream whitespace-separated Base64 into one preflighted // DER buffer. No normalized Base64 string or owned label/header copies are needed. -struct BorrowedPublicPem<'a> { +struct BorrowedPem<'a> { label: &'a str, data: &'a str, decoded_len: usize, } -impl<'a> BorrowedPublicPem<'a> { +impl<'a> BorrowedPem<'a> { fn parse(bytes: &'a [u8], maximum: usize) -> Result { if bytes.len() > maximum { return Err(import_resource_limit( @@ -2417,19 +2436,11 @@ impl<'a> BorrowedPublicPem<'a> { .strip_prefix("-----BEGIN ") .and_then(|rest| rest.split_once("-----")) .ok_or_else(invalid)?; - if !matches!(label, "PUBLIC KEY" | "RSA PUBLIC KEY") { - return Err(KeyStoreError::Selection("unsupported public PEM label")); - } let (payload, end) = rest .trim_start_matches([' ', '\t', '\n', '\r']) .split_once("-----END ") .ok_or_else(invalid)?; - let begin = if label == "PUBLIC KEY" { - "-----BEGIN PUBLIC KEY-----" - } else { - "-----BEGIN RSA PUBLIC KEY-----" - }; - if end.strip_prefix(label) != Some("-----") || payload.contains(begin) { + if end.strip_prefix(label) != Some("-----") || payload.contains("-----BEGIN ") { return Err(KeyStoreError::Selection( "PEM must contain one complete block", )); @@ -2475,6 +2486,12 @@ impl<'a> BorrowedPublicPem<'a> { } fn decode(&self) -> Result, KeyStoreError> { + let mut decoded = vec![0; self.decoded_len]; + self.decode_into(&mut decoded)?; + Ok(decoded) + } + + fn decode_into(&self, decoded: &mut [u8]) -> Result<(), KeyStoreError> { use std::io::Read as _; let input = PemBase64Reader { parts: self.data.split_whitespace(), @@ -2482,9 +2499,8 @@ impl<'a> BorrowedPublicPem<'a> { }; let mut decoder = base64::read::DecoderReader::new(input, &base64::engine::general_purpose::STANDARD); - let mut decoded = vec![0; self.decoded_len]; decoder - .read_exact(&mut decoded) + .read_exact(decoded) .map_err(|_| KeyStoreError::Selection("invalid PEM key"))?; let mut eof = [0]; if decoder @@ -2494,7 +2510,7 @@ impl<'a> BorrowedPublicPem<'a> { { return Err(KeyStoreError::Selection("invalid PEM key")); } - Ok(decoded) + Ok(()) } } @@ -2522,6 +2538,7 @@ impl std::io::Read for PemBase64Reader<'_> { } } +#[cfg(test)] fn single_pem_block(bytes: &[u8], maximum: usize) -> Result { if bytes.len() > maximum { return Err(import_resource_limit( @@ -2727,7 +2744,13 @@ fn kdf_policy_violation( } fn element_text(node: Node<'_, '_>) -> Result { - let mut text = String::new(); + let length = node + .children() + .filter(|child| child.is_text()) + .filter_map(|child| child.text()) + .map(str::len) + .sum(); + let mut text = String::with_capacity(length); for child in node.children() { if child.is_element() { return Err(KeyStoreError::Invalid("unexpected nested element".into())); @@ -2740,17 +2763,132 @@ fn element_text(node: Node<'_, '_>) -> Result { } 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())) + xml_base64_payload(node)? + .decode() + .map_err(|reason| KeyStoreError::Invalid(reason.into())) +} + +fn decode_xml_base64_secret(node: Node<'_, '_>) -> Result>, KeyStoreError> { + let payload = xml_base64_payload(node)?; + // Own the zeroizing guard before decoding: malformed trailing bits can + // fail after part of the secret has already been written into the buffer. + let mut output = Zeroizing::new(vec![0; payload.decoded_len]); + payload + .decode_into(&mut output) + .map_err(|reason| KeyStoreError::Invalid(reason.into()))?; + Ok(output) +} + +fn xml_base64_payload<'a, 'input>( + node: Node<'a, 'input>, +) -> Result, KeyStoreError> { + crate::xmldsig::whitespace::XmlBase64Payload::new(node) + .map_err(|reason| KeyStoreError::Invalid(reason.into())) +} + +struct XmlImportMaterialBudget { + used: usize, + maximum: usize, +} + +impl XmlImportMaterialBudget { + fn reserve(&mut self, length: usize) -> Result<(), KeyStoreError> { + if self.used > self.maximum || length > self.maximum - self.used { + return Err(import_resource_limit( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + self.maximum, + )); + } + self.used += length; + Ok(()) + } +} + +fn preflight_xml_material( + info: Node<'_, '_>, + budget: &mut XmlImportMaterialBudget, +) -> Result<(), KeyStoreError> { + // The source remains live while decoded entries and cloned names are + // retained. Charge them before importing any entry, including failed ones. + let key = info + .children() + .find(|child| child.has_tag_name((XMLDSIG_NS, "KeyValue"))) + .and_then(|value| value.children().find(|child| child.is_element())); + let name_copies = match key { + Some(key) if key.has_tag_name((XMLDSIG_NS, "DSAKeyValue")) => { + if key + .children() + .any(|child| child.has_tag_name((XMLSEC_NS, "X"))) + { + 4 + } else { + 3 + } + } + Some(key) + if key.has_tag_name((XMLDSIG_NS, "RSAKeyValue")) + || key.has_tag_name((XMLDSIG11_NS, "ECKeyValue")) => + { + 3 + } + _ => 2, + }; + let uses_shared_public_parser = key.is_some_and(|key| { + key.has_tag_name((XMLDSIG_NS, "RSAKeyValue")) + || key.has_tag_name((XMLDSIG11_NS, "ECKeyValue")) + }); + if uses_shared_public_parser { + crate::xmldsig::parse::validate_key_info_container(info) + .map_err(|error| KeyStoreError::Invalid(error.to_string()))?; + } + for child in info.children().filter(|child| child.is_element()) { + if child.has_tag_name((XMLDSIG_NS, "KeyName")) { + let length = child + .children() + .filter(|node| node.is_text()) + .filter_map(|node| node.text()) + .try_fold(0_usize, |sum, text| { + sum.checked_add(text.len()) + .ok_or(KeyStoreError::Selection("key material size overflow")) + })?; + // Inventory, duplicate-name set, KeyInfo and private DSA name. + if uses_shared_public_parser && length > crate::xmldsig::parse::MAX_KEY_NAME_TEXT_LEN { + return Err(KeyStoreError::Invalid( + "KeyName exceeds maximum allowed text length".into(), + )); + } + for _ in 0..name_copies { + budget.reserve(length)?; + } + } else if child.has_tag_name((XMLDSIG_NS, "KeyValue")) { + for key in child.children().filter(|node| node.is_element()) { + if key.has_tag_name((XMLSEC_NS, "HMACKeyValue")) + || key.has_tag_name((XMLSEC_NS, "AESKeyValue")) + || key.has_tag_name((XMLSEC_NS, "DESKeyValue")) + { + budget.reserve(xml_base64_payload(key)?.decoded_len)?; + } else if key.has_tag_name((XMLDSIG_NS, "RSAKeyValue")) + || key.has_tag_name((XMLDSIG_NS, "DSAKeyValue")) + || key.has_tag_name((XMLDSIG11_NS, "ECKeyValue")) + { + for component in key.children().filter(|node| node.is_element()) { + if component.has_tag_name((XMLDSIG11_NS, "NamedCurve")) { + budget.reserve(component.attribute("URI").map_or(0, str::len))?; + } else { + budget.reserve(xml_base64_payload(component)?.decoded_len)?; + } + } + } + } + } + } + Ok(()) } -fn parse_xmlsec_dsa_key_value(node: Node<'_, '_>) -> Result { +fn parse_xmlsec_dsa_key_value( + node: Node<'_, '_>, + budget: &mut XmlImportMaterialBudget, +) -> Result { let mut p = None; let mut q = None; let mut g = None; @@ -2775,7 +2913,7 @@ fn parse_xmlsec_dsa_key_value(node: Node<'_, '_>) -> Result) -> Result(); + let x_der_len = x_bytes_len + .checked_add(header + 1) + .ok_or(KeyStoreError::Selection("key material size overflow"))?; + let pkcs8_len = parameters_len + .checked_add(x_der_len) + .and_then(|length| length.checked_add(4 * header + 16)) + .ok_or(KeyStoreError::Selection("key material size overflow"))?; + budget.reserve(parameters_len)?; + budget.reserve(x_bytes_len)?; + budget.reserve(x_der_len)?; + budget.reserve(pkcs8_len)?; + budget.reserve(pkcs8_len)?; + Some(Zeroizing::new( + private + .to_pkcs8_der() + .map_err(|_| KeyStoreError::Invalid("DSA private key encoding failed".into()))? + .as_bytes() + .to_vec(), + )) + } else { + None }; - 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)) } @@ -3556,7 +3720,7 @@ mod tests { ) .expect("compact DSA store imports"); let retained = inventory.retained_material_bytes().expect("bounded tally"); - assert_eq!(inventory.material_bytes, xml.len().max(retained)); + assert_eq!(inventory.material_bytes, xml.len() + retained); assert!(retained >= inventory.private_keys[0].pkcs8_der.len()); assert!( retained > xml.len(), @@ -3612,7 +3776,8 @@ mod tests { assert_eq!(importer.finish().entry_count(), 0); let valid = xml("AA=="); let policy = xml_policy(ResourcePolicy { - max_external_resource_total_bytes: valid.len(), + // Source, decoded byte, inventory name, and duplicate-name copy. + max_external_resource_total_bytes: valid.len() + 3, ..ResourcePolicy::default() }); let mut importer = @@ -3622,7 +3787,7 @@ mod tests { .expect("first source fits exactly"); let consumed = importer.parse_work.consumed(); assert!(matches!( - importer.import(b"!"), + importer.import(b"!!"), Err(KeyStoreError::Policy( crate::policy::PolicyViolation::ResourceLimitExceeded { resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, @@ -4630,6 +4795,102 @@ mod tests { assert!(!identity.matches_spki(&changed)); } + #[test] + fn xml_import_counts_source_and_decoded_material_together() { + // The caller still owns the Base64 XML while the decoded HMAC is live. + let xml = format!( + "hmac{}", + base64::engine::general_purpose::STANDARD.encode(vec![1; 1024]) + ); + let resources = ResourcePolicy { + max_external_resource_total_bytes: xml.len(), + ..Default::default() + }; + assert!(matches!( + KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(resources), + XmlBackend::default() + ), + Err(KeyStoreError::Policy(_)) + )); + // Exact peak includes both live name copies, not just retained bytes. + let peak = xml.len() + 1024 + 2 * "hmac".len(); + for maximum in [peak - 1, peak] { + let policy = xml_policy(ResourcePolicy { + max_external_resource_total_bytes: maximum, + ..Default::default() + }); + let result = + KeyInventory::from_xml_bytes(xml.as_bytes(), &policy, XmlBackend::default()); + if maximum == peak { + let store = result.expect("exact live peak fits"); + assert_eq!(store.symmetric_keys[0].bytes.as_slice(), &[1; 1024]); + assert_eq!(store.material_bytes, xml.len() + 1024 + "hmac".len()); + } else { + assert!(matches!(result, Err(KeyStoreError::Policy(_)))); + } + } + } + + #[test] + fn private_pem_preflight_precedes_payload_parsing() { + // Structurally invalid DER must never be reached when its allocation + // already exceeds the allowance, for plain and encrypted labels alike. + for label in ["PRIVATE KEY", "ENCRYPTED PRIVATE KEY", "RSA PRIVATE KEY"] { + let pem = pem::encode(&pem::Pem::new(label, vec![1, 2, 3])); + let resources = ResourcePolicy { + max_external_resource_total_bytes: pem.len() + "key".len() + 2, + ..Default::default() + }; + let mut store = KeyInventory::default(); + assert!(matches!( + store.add_private_pem( + "key".into(), + pem.as_bytes(), + None, + KeyUsages::SIGN, + &resources + ), + Err(KeyStoreError::Policy(_)) + )); + assert_eq!(store.entry_count(), 0); + } + } + + #[test] + fn xml_public_key_import_preserves_key_info_metadata_validation() { + // Importing KeyValue directly must not bypass the original KeyInfo + // mixed-content and name-length validation performed by shared parsing. + let source = include_str!("../tests/fixtures/keys/xmlsec/mixed-keys.xml"); + let marker = source.find("").expect("RSA end") + "".len(); + let entry = &source[start..end]; + let name_start = entry.find("").expect("name") + "".len(); + let name_end = name_start + entry[name_start..].find("").expect("name end"); + for changed in [ + format!( + "{}{}{}", + &entry[..name_start], + "a".repeat(4097), + &entry[name_end..] + ), + entry.replacen("", "unexpected", 1), + ] { + let xml = format!("{changed}"); + assert!( + KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(ResourcePolicy::default()), + XmlBackend::default() + ) + .is_err() + ); + } + } + #[test] fn public_pem_decoder_preserves_framing_and_whitespace_contract() { // Decode borrowed input without tightening the existing whitespace/header @@ -4643,7 +4904,7 @@ mod tests { ] { let text = format!("-----BEGIN PUBLIC KEY-----\n{data}\n-----END PUBLIC KEY-----"); let expected = single_pem_block(text.as_bytes(), 4096).expect("existing PEM contract"); - let frame = BorrowedPublicPem::parse(text.as_bytes(), 4096).expect("borrowed frame"); + let frame = BorrowedPem::parse(text.as_bytes(), 4096).expect("borrowed frame"); assert_eq!( frame.decode().expect("borrowed decode"), expected.contents() @@ -4652,7 +4913,7 @@ mod tests { for data in ["AQI=", "AQ==", "AQID"] { let text = format!("-----BEGIN RSA PUBLIC KEY-----\n{data}\n-----END RSA PUBLIC KEY-----"); - let frame = BorrowedPublicPem::parse(text.as_bytes(), 4096).expect("borrowed frame"); + let frame = BorrowedPem::parse(text.as_bytes(), 4096).expect("borrowed frame"); assert_eq!(frame.decode().expect("decode").len(), frame.decoded_len); } // Exercise padding and the decoder's fixed-size staging boundaries; @@ -4660,7 +4921,7 @@ mod tests { for length in [0, 1, 2, 3, 1023, 1024, 1025, 4096] { let bytes = vec![0xa5; length]; let encoded = pem::encode(&pem::Pem::new("PUBLIC KEY", bytes.clone())); - let frame = BorrowedPublicPem::parse(encoded.as_bytes(), 8192).expect("frame"); + let frame = BorrowedPem::parse(encoded.as_bytes(), 8192).expect("frame"); assert_eq!(frame.decoded_len, length); assert_eq!(frame.decode().expect("streamed decoding"), bytes); } @@ -4676,7 +4937,7 @@ mod tests { "-----BEGIN PUBLIC KEY-----\nnot a header\n\nAQID\n-----END PUBLIC KEY-----", ] { assert!( - BorrowedPublicPem::parse(text.as_bytes(), 4096) + BorrowedPem::parse(text.as_bytes(), 4096) .and_then(|frame| frame.decode()) .is_err(), "accepted {text}" diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index f66468dc..396b32ab 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -33,10 +33,7 @@ use x509_parser::x509::X509Name; use super::digest::compute_digest; use super::digest::{DigestAlgorithm, compute_digest_with_provider, constant_time_eq}; use super::transforms::{self, Transform}; -use super::whitespace::{ - XmlBase64NormalizeLimitedError, is_xml_whitespace_only, normalize_xml_base64_text, - normalize_xml_base64_text_with_limit, -}; +use super::whitespace::{is_xml_whitespace_only, normalize_xml_base64_text}; use super::x509::certificate_signature_matches_with_provider; use crate::c14n::C14nAlgorithm; use crate::c14n::xml_base::{ @@ -54,7 +51,7 @@ pub(crate) const XMLDSIG11_NS: &str = "http://www.w3.org/2009/xmldsig11#"; const MAX_DER_ENCODED_KEY_VALUE_LEN: usize = 8192; const MAX_DER_ENCODED_KEY_VALUE_TEXT_LEN: usize = 65_536; const MAX_DER_ENCODED_KEY_VALUE_BASE64_LEN: usize = MAX_DER_ENCODED_KEY_VALUE_LEN.div_ceil(3) * 4; -const MAX_KEY_NAME_TEXT_LEN: usize = 4096; +pub(crate) const MAX_KEY_NAME_TEXT_LEN: usize = 4096; const MAX_KEY_INFO_CHILD_COUNT: usize = 64; const MAX_HMAC_OUTPUT_LENGTH_TEXT_LEN: usize = 32; const MAX_RETRIEVAL_XPATH_TEXT_LEN: usize = 256; @@ -848,8 +845,7 @@ fn parse_key_info_in_session( embedded_candidate_preflight_count: &mut usize, x509_total_binary_len: &mut usize, ) -> Result { - verify_ds_element(key_info_node, "KeyInfo")?; - ensure_no_non_whitespace_text(key_info_node, "KeyInfo")?; + validate_key_info_container(key_info_node)?; let mut sources = Vec::new(); // KeyInfo is parsed before source selection, so preflight the cardinality @@ -1119,7 +1115,7 @@ fn parse_inclusive_prefixes(node: Node) -> Result, ParseError> { Ok(None) } -fn parse_key_value_dispatch(node: Node) -> Result { +pub(crate) fn parse_key_value_dispatch(node: Node) -> Result { verify_ds_element(node, "KeyValue")?; ensure_no_non_whitespace_text(node, "KeyValue")?; @@ -1149,6 +1145,11 @@ fn parse_key_value_dispatch(node: Node) -> Result { } } +pub(crate) fn validate_key_info_container(node: Node) -> Result<(), ParseError> { + verify_ds_element(node, "KeyInfo")?; + ensure_no_non_whitespace_text(node, "KeyInfo") +} + fn parse_dsa_key_value(node: Node<'_, '_>) -> Result { verify_ds_element(node, "DSAKeyValue")?; ensure_no_non_whitespace_text(node, "DSAKeyValue")?; @@ -1335,45 +1336,27 @@ fn decode_crypto_binary( element_name: &'static str, max_decoded_len: usize, ) -> Result, ParseError> { - use base64::Engine; - use base64::engine::general_purpose::STANDARD; - let max_base64_len = max_decoded_len.div_ceil(3) * 4; - let mut cleaned = String::with_capacity(max_base64_len); - for text in node - .children() - .filter(|child| child.is_text()) - .filter_map(|child| child.text()) - { - normalize_xml_base64_text_with_limit(text, &mut cleaned, max_base64_len).map_err( - |err| match err { - XmlBase64NormalizeLimitedError::InvalidWhitespace(err) => { - ParseError::Base64(format!( - "invalid XML whitespace U+{:04X} in {element_name}", - err.invalid_byte - )) - } - XmlBase64NormalizeLimitedError::TooLong(_) => ParseError::InvalidStructure( - format!("{element_name} exceeds maximum allowed base64 length"), - ), - }, - )?; + let payload = super::whitespace::XmlBase64Payload::bounded(node, usize::MAX, max_base64_len) + .map_err(|reason| xml_base64_payload_error(element_name, reason))?; + if payload.normalized_len > max_base64_len { + return Err(ParseError::InvalidStructure(format!( + "{element_name} exceeds maximum allowed base64 length" + ))); } - - let value = STANDARD - .decode(&cleaned) - .map_err(|err| ParseError::Base64(format!("{element_name}: {err}")))?; - if value.is_empty() { + if payload.decoded_len == 0 { return Err(ParseError::InvalidStructure(format!( "{element_name} must not be empty" ))); } - if value.len() > max_decoded_len { + if payload.decoded_len > max_decoded_len { return Err(ParseError::InvalidStructure(format!( "{element_name} exceeds maximum allowed binary length" ))); } - Ok(value) + payload + .decode() + .map_err(|reason| ParseError::Base64(format!("{element_name}: {reason}"))) } pub(crate) fn parse_x509_data_dispatch_with_budget_and_provider( @@ -1393,8 +1376,8 @@ pub(crate) fn parse_x509_data_dispatch_with_budget_and_provider( charge_embedded_key_candidate(embedded_key_candidates, resources)?; ensure_no_element_children(child, "X509Certificate")?; ensure_x509_data_entry_budget(&info)?; - let cert = decode_x509_base64(child, "X509Certificate")?; - add_x509_data_usage(total_binary_len, cert.len())?; + let cert = + decode_x509_base64(child, "X509Certificate", total_binary_len, resources)?; let parsed_cert = parse_x509_certificate(cert.as_slice())?; info.parsed_certificates.push(parsed_cert); info.certificates.push(cert); @@ -1417,23 +1400,20 @@ pub(crate) fn parse_x509_data_dispatch_with_budget_and_provider( (Some(XMLDSIG_NS), "X509SKI") => { ensure_no_element_children(child, "X509SKI")?; ensure_x509_data_entry_budget(&info)?; - let ski = decode_x509_base64(child, "X509SKI")?; - add_x509_data_usage(total_binary_len, ski.len())?; + let ski = decode_x509_base64(child, "X509SKI", total_binary_len, resources)?; info.skis.push(ski); } (Some(XMLDSIG_NS), "X509CRL") => { ensure_no_element_children(child, "X509CRL")?; ensure_x509_data_entry_budget(&info)?; - let crl = decode_x509_base64(child, "X509CRL")?; - add_x509_data_usage(total_binary_len, crl.len())?; + let crl = decode_x509_base64(child, "X509CRL", total_binary_len, resources)?; info.crls.push(crl); } (Some(XMLDSIG11_NS), "X509Digest") => { ensure_no_element_children(child, "X509Digest")?; ensure_x509_data_entry_budget(&info)?; let algorithm = required_algorithm_attr(child, "X509Digest")?; - let digest = decode_x509_base64(child, "X509Digest")?; - add_x509_data_usage(total_binary_len, digest.len())?; + let digest = decode_x509_base64(child, "X509Digest", total_binary_len, resources)?; info.digests.push((algorithm.to_string(), digest)); } (Some(XMLDSIG_NS), child_name) | (Some(XMLDSIG11_NS), child_name) => { @@ -2136,50 +2116,66 @@ fn add_x509_data_usage(total_binary_len: &mut usize, delta: usize) -> Result<(), fn decode_x509_base64( node: Node<'_, '_>, element_name: &'static str, + total_binary_len: &mut usize, + resources: &crate::policy::ResourcePolicy, ) -> Result, ParseError> { - use base64::Engine; - use base64::engine::general_purpose::STANDARD; - - let mut cleaned = String::new(); - let mut raw_text_len = 0usize; - for text in node - .children() - .filter(|child| child.is_text()) - .filter_map(|child| child.text()) - { - if raw_text_len.saturating_add(text.len()) > MAX_X509_BASE64_TEXT_LEN { - return Err(ParseError::InvalidStructure(format!( - "{element_name} exceeds maximum allowed text length" - ))); - } - raw_text_len = raw_text_len.saturating_add(text.len()); - normalize_xml_base64_text(text, &mut cleaned).map_err(|err| { - ParseError::Base64(format!( - "invalid XML whitespace U+{:04X} in {element_name}", - err.invalid_byte - )) - })?; - if cleaned.len() > MAX_X509_BASE64_NORMALIZED_LEN { - return Err(ParseError::InvalidStructure(format!( - "{element_name} exceeds maximum allowed base64 length" - ))); - } + let payload = super::whitespace::XmlBase64Payload::bounded( + node, + MAX_X509_BASE64_TEXT_LEN, + MAX_X509_BASE64_NORMALIZED_LEN, + ) + .map_err(|reason| xml_base64_payload_error(element_name, reason))?; + if payload.text_len > MAX_X509_BASE64_TEXT_LEN { + return Err(ParseError::InvalidStructure(format!( + "{element_name} exceeds maximum allowed text length" + ))); } - - let decoded = STANDARD - .decode(&cleaned) - .map_err(|e| ParseError::Base64(format!("{element_name}: {e}")))?; - if decoded.is_empty() { + if payload.normalized_len > MAX_X509_BASE64_NORMALIZED_LEN { + return Err(ParseError::InvalidStructure(format!( + "{element_name} exceeds maximum allowed base64 length" + ))); + } + if payload.decoded_len == 0 { return Err(ParseError::InvalidStructure(format!( "{element_name} must not be empty" ))); } - if decoded.len() > MAX_X509_DECODED_BINARY_LEN { + if payload.decoded_len > MAX_X509_DECODED_BINARY_LEN { return Err(ParseError::InvalidStructure(format!( "{element_name} exceeds maximum allowed binary length" ))); } - Ok(decoded) + if payload.decoded_len > resources.max_external_resource_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_bytes, + } + .into()); + } + if *total_binary_len > resources.max_external_resource_total_bytes + || payload.decoded_len > resources.max_external_resource_total_bytes - *total_binary_len + { + return Err(crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_total_bytes, + } + .into()); + } + // Reserve before allocation/DER parsing. Failed candidates consume the + // session budget too; retrying malformed data must not reset spent work. + add_x509_data_usage(total_binary_len, payload.decoded_len)?; + payload + .decode() + .map_err(|reason| ParseError::Base64(format!("{element_name}: {reason}"))) +} + +fn xml_base64_payload_error(element: &str, reason: &'static str) -> ParseError { + match reason { + "maximum allowed text length" | "maximum allowed base64 length" => { + ParseError::InvalidStructure(format!("{element} exceeds {reason}")) + } + _ => ParseError::Base64(format!("{element}: {reason}")), + } } pub(crate) fn parse_x509_certificate(cert_der: &[u8]) -> Result { @@ -2708,6 +2704,84 @@ mod tests { // โ”€โ”€ parse_key_info: dispatch parsing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + #[test] + fn key_info_session_applies_x509_byte_limits_before_decode() { + // SKI is binary X.509 metadata too; repeated parses share one allowance. + let document = Document::parse("AQID").unwrap(); + for individual in [true, false] { + let resources = crate::policy::ResourcePolicy { + max_external_resource_bytes: if individual { 2 } else { 3 }, + max_external_resource_total_bytes: if individual { 100 } else { 5 }, + ..Default::default() + }; + let mut session = KeyInfoParsingSession::new(&resources).unwrap(); + if !individual { + session.parse(document.root_element()).unwrap(); + } + assert!(matches!( + session.parse(document.root_element()), + Err(ParseError::Policy(_)) + )); + } + } + + #[test] + fn all_x509_binary_fields_preflight_active_byte_policy() { + // Even invalid certificate DER must be rejected by the smaller active + // byte limit before certificate parsing, just like SKI/CRL/digest data. + for element in ["X509Certificate", "X509SKI", "X509CRL", "X509Digest"] { + let namespace = if element == "X509Digest" { + XMLDSIG11_NS + } else { + XMLDSIG_NS + }; + let xml = format!( + "AQID" + ); + let document = Document::parse(&xml).unwrap(); + let resources = crate::policy::ResourcePolicy { + max_external_resource_bytes: 2, + ..Default::default() + }; + let mut session = KeyInfoParsingSession::new(&resources).unwrap(); + assert!( + matches!( + session.parse(document.root_element()), + Err(ParseError::Policy(_)) + ), + "{element}" + ); + } + } + + #[test] + fn failed_x509_der_parse_keeps_session_byte_charge() { + // A failed certificate attempt has already decoded three bytes. A + // subsequent valid SKI cannot reuse those bytes' aggregate allowance. + let invalid = Document::parse("AQID").unwrap(); + let valid = Document::parse("AQID").unwrap(); + let resources = crate::policy::ResourcePolicy { + max_external_resource_bytes: 3, + max_external_resource_total_bytes: 5, + ..Default::default() + }; + let mut session = KeyInfoParsingSession::new(&resources).unwrap(); + assert!(matches!( + session.parse(invalid.root_element()), + Err(ParseError::InvalidStructure(_)) + )); + assert!(matches!( + session.parse(valid.root_element()), + Err(ParseError::Policy(_)) + )); + assert!( + KeyInfoParsingSession::new(&resources) + .unwrap() + .parse(valid.root_element()) + .is_ok() + ); + } + #[test] fn key_info_session_retains_failed_work_and_applies_active_policy() { // A failed KeyValue has already consumed inspection work; a second diff --git a/src/xmldsig/whitespace.rs b/src/xmldsig/whitespace.rs index b3c4305b..601c481f 100644 --- a/src/xmldsig/whitespace.rs +++ b/src/xmldsig/whitespace.rs @@ -1,5 +1,127 @@ //! Internal XML whitespace helpers shared across XMLDSig parsing and verification. +/// Borrow XML text and validate its exact decoded size without normalizing it +/// into a second heap buffer. Callers reserve this size before calling decode. +pub(crate) struct XmlBase64Payload<'a, 'input> { + node: crate::xml::dom::Node<'a, 'input>, + pub(crate) decoded_len: usize, + pub(crate) normalized_len: usize, + pub(crate) text_len: usize, +} + +impl<'a, 'input> XmlBase64Payload<'a, 'input> { + pub(crate) fn new(node: crate::xml::dom::Node<'a, 'input>) -> Result { + Self::bounded(node, usize::MAX, usize::MAX) + } + + pub(crate) fn bounded( + node: crate::xml::dom::Node<'a, 'input>, + max_text: usize, + max_normalized: usize, + ) -> Result { + let mut normalized_len = 0_usize; + let mut padding = 0_usize; + let mut text_len = 0_usize; + for child in node.children() { + if child.is_element() { + return Err("unexpected nested element"); + } + if !child.is_text() { + continue; + } + let text = child.text().unwrap_or_default(); + if text.len() > max_text - text_len { + return Err("maximum allowed text length"); + } + text_len = text_len + .checked_add(text.len()) + .ok_or("base64 size overflow")?; + for byte in text.bytes() { + if matches!(byte, b' ' | b'\t' | b'\r' | b'\n') { + continue; + } + if normalized_len >= max_normalized { + return Err("maximum allowed base64 length"); + } + if byte == b'=' { + padding += 1; + if padding > 2 { + return Err("invalid base64 padding"); + } + } else if padding != 0 + || !matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/') + { + return Err("invalid base64 character or XML whitespace"); + } + normalized_len = normalized_len + .checked_add(1) + .ok_or("base64 size overflow")?; + } + } + if !normalized_len.is_multiple_of(4) { + return Err("invalid base64 length"); + } + let decoded_len = (normalized_len / 4 * 3) + .checked_sub(padding) + .ok_or("invalid base64 padding")?; + Ok(Self { + node, + decoded_len, + normalized_len, + text_len, + }) + } + + pub(crate) fn decode(&self) -> Result, &'static str> { + let mut output = vec![0; self.decoded_len]; + self.decode_into(&mut output)?; + Ok(output) + } + + pub(crate) fn decode_into(&self, output: &mut [u8]) -> Result<(), &'static str> { + use std::io::Read as _; + let input = self + .node + .children() + .filter(|child| child.is_text()) + .filter_map(|child| child.text()) + .flat_map(str::bytes) + .filter(|byte| !matches!(byte, b' ' | b'\t' | b'\r' | b'\n')); + let mut decoder = base64::read::DecoderReader::new( + Base64ByteReader(input), + &base64::engine::general_purpose::STANDARD, + ); + decoder + .read_exact(output) + .map_err(|_| "invalid base64 padding or trailing bits")?; + let mut eof = [0]; + if decoder + .read(&mut eof) + .map_err(|_| "invalid base64 padding or trailing bits")? + != 0 + { + return Err("invalid base64 length"); + } + Ok(()) + } +} + +struct Base64ByteReader(I); + +impl> std::io::Read for Base64ByteReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let mut written = 0; + for slot in buffer { + let Some(byte) = self.0.next() else { + break; + }; + *slot = byte; + written += 1; + } + Ok(written) + } +} + /// Return `true` when the text contains only XML 1.0 whitespace chars. #[inline] pub(crate) fn is_xml_whitespace_only(text: &str) -> bool { @@ -125,6 +247,41 @@ mod tests { normalize_xml_base64_text_with_limit, }; + #[test] + fn borrowed_base64_stream_crosses_text_and_decoder_buffer_boundaries() { + // Text/comment boundaries and the decoder's internal chunk size must + // not become Base64 framing boundaries or require a normalized copy. + use base64::Engine as _; + let bytes = vec![7; 4097]; + let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); + let xml = format!( + " {}{}\n", + &encoded[..3], + &encoded[3..] + ); + let document = crate::xml::dom::Document::parse(&xml).expect("segmented Base64 XML"); + let payload = + super::XmlBase64Payload::new(document.root_element()).expect("valid Base64 frame"); + assert_eq!(payload.decoded_len, bytes.len()); + let decoded = payload.decode().expect("segmented stream decodes"); + assert_eq!(decoded.capacity(), bytes.len()); + assert_eq!(decoded, bytes); + } + + #[test] + fn borrowed_base64_rejects_noncanonical_padding_and_non_xml_whitespace() { + // Invalid trailing bits are checked by the stream decoder even when + // lexical preflight accepts the alphabet and exact decoded length. + for encoded in ["AR==", "AQJ=", "AQ==AQ==", "AQID\u{a0}", "AQI"] { + let xml = format!("{encoded}"); + let document = + crate::xml::dom::Document::parse(&xml).expect("invalid Base64 in valid XML"); + let result = super::XmlBase64Payload::new(document.root_element()) + .and_then(|payload| payload.decode()); + assert!(result.is_err(), "{encoded}"); + } + } + #[test] fn bounded_base64_normalization_rejects_before_growth() { let mut normalized = String::from("ABCD"); From a7201402c7c08e6e94444fe430dda114f9987c4c Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Fri, 2 Oct 2026 15:35:39 +0300 Subject: [PATCH 8/9] fix(keys): share operation import budgets Account for certificate buffers during XML key-store import. Preserve KDF work across protected-key candidates, failed decrypts and inventory merges without retaining temporary key copies. --- docs/key-management.md | 7 + src/key_manager.rs | 195 ++++++++++++++++---- src/key_manager/pkcs12_import.rs | 89 ++++++--- tools/xmlsec1/src/commands.rs | 293 +++++++++++++++++++++++------- tools/xmlsec1/src/key_material.rs | 21 ++- 5 files changed, 484 insertions(+), 121 deletions(-) diff --git a/docs/key-management.md b/docs/key-management.md index ccb818f9..c4b04af3 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -113,6 +113,10 @@ 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. +KDF work accumulates across imports in one inventory, including failed decrypts. +The CLI carries that usage across temporary signing and decryption candidates, +so lax search cannot multiply the operation allowance. Temporary inventories +are released after extracting the candidate; accounting does not retain extra keys. 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 @@ -168,6 +172,9 @@ retained material. XML Base64 decoding borrows text nodes and uses one exact-siz output buffer without normalized text copies. Embedded X509Data binary values obey the active per-resource and aggregate byte limits before decoding; repeated KeyInfo parses in one session retain that charge, including failed candidates. +XML import sessions can also account for material retained outside the inventory; +the CLI seeds this usage from its shared certificate/file ledger before loading +`--keys-file`, so live certificate buffers cannot reuse the key-import allowance. 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 f3a31f1f..bde4afef 100644 --- a/src/key_manager.rs +++ b/src/key_manager.rs @@ -169,6 +169,7 @@ pub struct KeyInventory { /// Caller-supplied DER certificate revocation lists. crls: Vec>, material_bytes: usize, + kdf_work: usize, } /// Import session for multiple XML key stores under one operation snapshot. @@ -180,18 +181,36 @@ pub struct XmlKeyStoreImporter<'a, P: crate::document::XmlDocumentPolicy> { parse_work: XmlParseWorkBudget, inspected: usize, inventory: KeyInventory, + live_material_bytes: usize, } 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 { + Self::with_live_material(policy, backend, 0) + } + + /// Bind import accounting to material already retained by the operation. + /// This is usage, not a second policy limit; it remains live until finish. + pub fn with_live_material( + policy: &'a P, + backend: XmlBackend, + live_material_bytes: usize, + ) -> Result { ensure_resource_policy(policy.resource_policy())?; + if live_material_bytes > policy.resource_policy().max_external_resource_total_bytes { + return Err(import_resource_limit( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + policy.resource_policy().max_external_resource_total_bytes, + )); + } Ok(Self { policy, backend, parse_work: XmlParseWorkBudget::from_resources(policy.resource_policy()), inspected: 0, inventory: KeyInventory::default(), + live_material_bytes, }) } @@ -204,7 +223,17 @@ impl<'a, P: crate::document::XmlDocumentPolicy> XmlKeyStoreImporter<'a, P> { self.backend, &self.parse_work, &mut self.inspected, - self.inventory.material_bytes, + self.inventory + .material_bytes + .checked_add(self.live_material_bytes) + .ok_or_else(|| { + import_resource_limit( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + self.policy + .resource_policy() + .max_external_resource_total_bytes, + ) + })?, )?; self.inventory.extend(store, self.policy.resource_policy()) } @@ -724,6 +753,14 @@ pub enum KeyStoreError { } impl KeyInventory { + /// Work reserved before password derivation, including unsuccessful imports. + /// An operation importing through temporary inventories must carry this + /// usage forward rather than resetting its remaining KDF allowance. + #[must_use] + pub fn key_import_kdf_work(&self) -> usize { + self.kdf_work + } + fn retained_material_bytes(&self) -> Result { let mut total = 0_usize; let mut add = |length: usize| -> Result<(), KeyStoreError> { @@ -808,13 +845,23 @@ impl KeyInventory { } /// Combine two caller-owned imports after checking aggregate bytes, - /// candidates, and cross-store name collisions before mutating either. + /// candidates, KDF work, and cross-store name collisions before mutation. pub fn extend( &mut self, mut other: Self, resources: &ResourcePolicy, ) -> Result<(), KeyStoreError> { ensure_resource_policy(resources)?; + let work = self + .kdf_work + .checked_add(other.kdf_work) + .filter(|work| *work <= resources.max_key_import_kdf_work) + .ok_or_else(|| { + import_resource_limit( + crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + resources.max_key_import_kdf_work, + ) + })?; let candidates = self .entry_count .checked_add(other.entry_count) @@ -854,6 +901,7 @@ impl KeyInventory { } self.entry_count = candidates; self.material_bytes = bytes; + self.kdf_work = work; self.symmetric_keys.append(&mut other.symmetric_keys); self.public_keys.append(&mut other.public_keys); self.private_keys.append(&mut other.private_keys); @@ -1384,7 +1432,17 @@ 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)?; + let mut remaining = resources.clone(); + if self.kdf_work > remaining.max_key_import_kdf_work { + return Err(import_resource_limit( + crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + resources.max_key_import_kdf_work, + )); + } + remaining.max_key_import_kdf_work -= self.kdf_work; + let work = + enforce_pkcs8_password_policy(&encrypted, password, &remaining, kdf_live_bytes)?; + self.kdf_work += work; // 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()); @@ -1607,9 +1665,10 @@ impl KeyInventory { F: FnOnce() -> Option>, { let limits = self.pkcs12_import_limits(&name, bytes, usages, resources)?; - let prepared = pkcs12_import::prepare(bytes, &limits)?; + let prepared = pkcs12_import::prepare_with_work(bytes, &limits, self.kdf_work)?; let secret = password().ok_or(KeyStoreError::ProtectedContainer)?; - let contents = prepared.decrypt_with_password_capacity(&secret, secret.capacity())?; + let contents = + prepared.decrypt_with_work(&secret, secret.capacity(), &mut self.kdf_work)?; self.add_pkcs12_contents(name, bytes.len(), contents, usages, resources, false) } @@ -1635,7 +1694,8 @@ impl KeyInventory { auto_decrypt: bool, ) -> Result<(), KeyStoreError> { let limits = self.pkcs12_import_limits(&name, bytes, usages, resources)?; - let contents = pkcs12_import::prepare(bytes, &limits)?.decrypt(password)?; + let contents = pkcs12_import::prepare_with_work(bytes, &limits, self.kdf_work)? + .decrypt_with_work(password, password.len(), &mut self.kdf_work)?; self.add_pkcs12_contents(name, bytes.len(), contents, usages, resources, auto_decrypt) } @@ -2568,7 +2628,7 @@ fn enforce_pkcs8_kdf_policy( encrypted: &EncryptedPrivateKeyInfoRef<'_>, resources: &ResourcePolicy, retained_with_input: usize, -) -> Result<(), KeyStoreError> { +) -> Result { 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. @@ -2589,7 +2649,7 @@ fn enforce_pkcs8_kdf_policy( )); } let live_with_output = live_with_output.ok_or(KeyStoreError::ProtectedContainer)?; - match ¶ms.kdf { + let work = match ¶ms.kdf { Kdf::Pbkdf2(kdf) => { if kdf.iteration_count == 0 { return Err(KeyStoreError::ProtectedContainer); @@ -2615,19 +2675,18 @@ fn enforce_pkcs8_kdf_policy( work, )); } + work.ok_or(KeyStoreError::ProtectedContainer)? as usize } - Kdf::Scrypt(kdf) => { - enforce_scrypt_kdf_limits( - kdf.cost_parameter, - u64::from(kdf.block_size), - u64::from(kdf.parallelization), - resources, - live_with_output, - )?; - } + Kdf::Scrypt(kdf) => enforce_scrypt_kdf_limits( + kdf.cost_parameter, + u64::from(kdf.block_size), + u64::from(kdf.parallelization), + resources, + live_with_output, + )?, _ => return Err(KeyStoreError::ProtectedContainer), - } - Ok(()) + }; + Ok(work) } fn enforce_pkcs8_password_policy( @@ -2635,7 +2694,7 @@ fn enforce_pkcs8_password_policy( password: &[u8], resources: &ResourcePolicy, live_bytes: usize, -) -> Result<(), KeyStoreError> { +) -> Result { if password.len() > resources.max_external_resource_bytes { return Err(kdf_policy_violation( crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, @@ -2670,18 +2729,20 @@ fn enforce_pkcs8_password_policy( } 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, - }) + enforce_pkcs8_kdf_policy(encrypted, &remaining, live) + .map(|work| work + password_work) + .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( @@ -2690,7 +2751,7 @@ fn enforce_scrypt_kdf_limits( p: u64, resources: &ResourcePolicy, retained_with_input: usize, -) -> Result<(), KeyStoreError> { +) -> Result { if n == 0 || r == 0 || p == 0 { return Err(KeyStoreError::ProtectedContainer); } @@ -2726,7 +2787,8 @@ fn enforce_scrypt_kdf_limits( total, )); } - Ok(()) + // The work check above proves this value exists and fits the usize ceiling. + Ok(work.ok_or(KeyStoreError::ProtectedContainer)? as usize) } fn kdf_policy_violation( @@ -3045,6 +3107,71 @@ mod tests { } } + #[test] + fn inventory_merge_preserves_import_work() { + // Moving inventories must not discard work spent before the merge. + let resources = ResourcePolicy { + max_key_import_kdf_work: 5, + ..Default::default() + }; + let mut inventory = KeyInventory { + kdf_work: 2, + ..Default::default() + }; + inventory + .extend( + KeyInventory { + kdf_work: 3, + ..Default::default() + }, + &resources, + ) + .expect("exact work allowance"); + assert_eq!(inventory.key_import_kdf_work(), 5); + assert!(matches!( + inventory.extend( + KeyInventory { + kdf_work: 1, + ..Default::default() + }, + &resources + ), + Err(KeyStoreError::Policy(_)) + )); + assert_eq!(inventory.key_import_kdf_work(), 5, "failed merge is atomic"); + } + + #[test] + fn repeated_pkcs12_imports_share_kdf_work() { + // Each bundle fits by itself, but a session may not reset password work. + let bytes = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + let resources = ResourcePolicy { + max_key_import_kdf_work: 10_000, + ..ResourcePolicy::default() + }; + let mut inventory = KeyInventory::default(); + inventory + .add_pkcs12("first".into(), bytes, "secret", &resources) + .expect("first bundle fits"); + assert!(matches!( + inventory.add_pkcs12("second".into(), bytes, "secret", &resources), + Err(KeyStoreError::Policy(_)) + )); + let work = inventory.key_import_kdf_work(); + let exact = ResourcePolicy { + max_key_import_kdf_work: work * 2, + ..resources + }; + let mut inventory = KeyInventory::default(); + inventory + .add_pkcs12("first".into(), bytes, "secret", &exact) + .expect("first exact-boundary bundle"); + inventory + .add_pkcs12("second".into(), bytes, "secret", &exact) + .expect("second exact-boundary bundle"); + assert_eq!(inventory.key_import_kdf_work(), work * 2); + } + #[test] fn imports_donor_pkcs12_and_rejects_wrong_password() { // A real upstream PHAOS bundle exercises MAC, password decoding, key diff --git a/src/key_manager/pkcs12_import.rs b/src/key_manager/pkcs12_import.rs index 0d9448e0..dac159de 100644 --- a/src/key_manager/pkcs12_import.rs +++ b/src/key_manager/pkcs12_import.rs @@ -1153,8 +1153,24 @@ pub(super) struct Prepared<'a, 'l> { limits: &'l Limits, } +#[cfg(test)] pub(super) fn prepare<'a, 'l>(bytes: &'a [u8], limits: &'l Limits) -> Result> { + prepare_with_work(bytes, limits, 0) +} + +pub(super) fn prepare_with_work<'a, 'l>( + bytes: &'a [u8], + limits: &'l Limits, + work: usize, +) -> Result> { let mut budget = Budget::new(limits); + if work > limits.resources.max_key_import_kdf_work { + return Err(denial( + resource_name::KEY_IMPORT_KDF_WORK, + limits.resources.max_key_import_kdf_work, + )); + } + budget.work = work; let pfx = Pfx::parse(bytes, &mut budget)?; walk_safe( &pfx.safe, @@ -1169,45 +1185,68 @@ pub(super) fn prepare<'a, 'l>(bytes: &'a [u8], limits: &'l Limits) -> Result
 {
+    #[cfg(test)]
     pub(super) fn decrypt(self, password: &str) -> Result {
         self.decrypt_with_password_capacity(password, password.len())
     }
 
+    #[cfg(test)]
     pub(super) fn decrypt_with_password_capacity(
         self,
         password: &str,
         capacity: usize,
+    ) -> Result {
+        self.decrypt_with_work(password, capacity, &mut 0)
+    }
+
+    pub(super) fn decrypt_with_work(
+        self,
+        password: &str,
+        capacity: usize,
+        work: &mut 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 {
+        if *work > limits.resources.max_key_import_kdf_work {
             return Err(denial(
-                resource_name::EXTERNAL_RESOURCE_BYTES,
-                limits.resources.max_external_resource_bytes,
+                resource_name::KEY_IMPORT_KDF_WORK,
+                limits.resources.max_key_import_kdf_work,
             ));
         }
-        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())?;
-            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)
+        budget.work = *work;
+        let result = (|| {
+            // 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())?;
+                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)
+        })();
+        // Work is not refunded by password, DER, or later association failures.
+        *work = budget.work;
+        result
     }
 }
 
diff --git a/tools/xmlsec1/src/commands.rs b/tools/xmlsec1/src/commands.rs
index d12a0c5f..2a39fa64 100644
--- a/tools/xmlsec1/src/commands.rs
+++ b/tools/xmlsec1/src/commands.rs
@@ -711,7 +711,8 @@ fn load_xml_key_stores(
     backend: XmlBackend,
     budget: &mut ExternalMaterialBudget,
 ) -> Result {
-    let mut importer = key_manager::XmlKeyStoreImporter::new(policy, backend)?;
+    let mut importer =
+        key_manager::XmlKeyStoreImporter::with_live_material(policy, backend, budget.total_bytes)?;
     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)?;
@@ -1054,6 +1055,22 @@ fn prepare_signing_key_candidate(
     policy: &SigningPolicy,
     password: Option<&[u8]>,
     material_budget: &mut ExternalMaterialBudget,
+) -> Result {
+    material_budget.with_key_import(&policy.resources, |budget, inventory, resources| {
+        prepare_signing_key_candidate_inner(
+            option, algorithm, policy, password, budget, inventory, resources,
+        )
+    })
+}
+
+fn prepare_signing_key_candidate_inner(
+    option: &crate::OptionValue,
+    algorithm: SignatureAlgorithm,
+    policy: &SigningPolicy,
+    password: Option<&[u8]>,
+    material_budget: &mut ExternalMaterialBudget,
+    inventory: &mut KeyInventory,
+    resources: &xml_sec::policy::ResourcePolicy,
 ) -> Result {
     if algorithm.hmac_output_bits().is_some() {
         let path = option.value.as_deref().unwrap_or_default();
@@ -1075,9 +1092,8 @@ fn prepare_signing_key_candidate(
         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)?;
+        inventory.add_pkcs12(name.clone(), &bytes, password, resources)?;
         let key = inventory.signing_key(&name, algorithm, policy)?;
         let imported = inventory
             .private_keys()
@@ -1110,7 +1126,6 @@ fn prepare_signing_key_candidate(
     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 => {
@@ -1119,7 +1134,7 @@ fn prepare_signing_key_candidate(
                     &key_bytes,
                     password,
                     key_manager::KeyUsages::SIGN,
-                    &policy.resources,
+                    resources,
                 )?;
             }
             key_material::PrivateKeyFormat::Der | key_material::PrivateKeyFormat::Pkcs8Der => {
@@ -1128,7 +1143,7 @@ fn prepare_signing_key_candidate(
                     &key_bytes,
                     password,
                     key_manager::KeyUsages::SIGN,
-                    &policy.resources,
+                    resources,
                 )?;
             }
         }
@@ -1589,6 +1604,7 @@ fn verify(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Command
 struct ExternalMaterialBudget {
     total_bytes: usize,
     maximum_bytes: usize,
+    kdf_work: usize,
 }
 
 #[derive(Clone, Default)]
@@ -1651,6 +1667,7 @@ impl ExternalMaterialBudget {
         Self {
             total_bytes: 0,
             maximum_bytes,
+            kdf_work: 0,
         }
     }
 
@@ -1668,6 +1685,34 @@ impl ExternalMaterialBudget {
     fn remaining(&self) -> usize {
         self.maximum_bytes - self.total_bytes
     }
+
+    fn with_key_import(
+        &mut self,
+        resources: &xml_sec::policy::ResourcePolicy,
+        import: impl FnOnce(
+            &mut Self,
+            &mut KeyInventory,
+            &xml_sec::policy::ResourcePolicy,
+        ) -> Result,
+    ) -> Result {
+        if self.kdf_work > resources.max_key_import_kdf_work {
+            return Err(key_manager::KeyStoreError::Policy(
+                xml_sec::policy::PolicyViolation::ResourceLimitExceeded {
+                    resource: "key import KDF work",
+                    maximum: resources.max_key_import_kdf_work,
+                },
+            )
+            .into());
+        }
+        let mut remaining = resources.clone();
+        remaining.max_key_import_kdf_work -= self.kdf_work;
+        let mut inventory = KeyInventory::default();
+        let result = import(self, &mut inventory, &remaining);
+        // Retain actual work on every result, but release the temporary encoded
+        // inventory when its native key has been extracted. No key copies linger.
+        self.kdf_work += inventory.key_import_kdf_work();
+        result
+    }
 }
 
 fn read_key_material_with_budget(
@@ -3560,64 +3605,66 @@ fn decrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman
         let mut certificate_budget =
             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,
+            let loaded = certificate_budget.with_key_import(
+                &policy.resources,
+                |certificate_budget, inventory, resources| {
+                    if option.name == "pkcs12" {
+                        let path = Path::new(option.value.as_deref().unwrap_or_default());
+                        let bytes = read_key_material_with_budget(path, certificate_budget)?;
+                        let password = password
+                            .and_then(|value| std::str::from_utf8(value).ok())
+                            .ok_or(key_manager::KeyStoreError::ProtectedContainer)?;
+                        let name = option.parameter.clone().unwrap_or_else(|| "pkcs12".into());
+                        inventory.add_pkcs12(name.clone(), &bytes, password, 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 = read_key_material_with_budget(Path::new(path), certificate_budget)?;
+                    let private_key = key_material::decode_rsa_private_with_inventory(
+                        Path::new(path),
+                        &bytes,
+                        private_key_format(option),
+                        password,
+                        resources,
+                        inventory,
                     )?;
-                    return Ok(RecipientPrivateKey {
+                    if !certificate_paths.is_empty() {
+                        let encoding = if matches!(
+                            private_key_format(option),
+                            key_material::PrivateKeyFormat::Der
+                                | key_material::PrivateKeyFormat::Pkcs8Der
+                        ) {
+                            key_material::CertificateEncoding::Der
+                        } else {
+                            key_material::CertificateEncoding::Pem
+                        };
+                        let certificates = load_certificate_companions(
+                            &certificate_paths,
+                            encoding,
+                            certificate_budget,
+                        )?;
+                        ensure_leaf_certificate_matches_rsa_key(&certificates[0], &private_key)?;
+                    }
+                    Ok::<_, CommandError>(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 =
-                    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!(
-                        private_key_format(option),
-                        key_material::PrivateKeyFormat::Der
-                            | key_material::PrivateKeyFormat::Pkcs8Der
-                    ) {
-                        key_material::CertificateEncoding::Der
-                    } else {
-                        key_material::CertificateEncoding::Pem
-                    };
-                    let certificates = load_certificate_companions(
-                        &certificate_paths,
-                        encoding,
-                        &mut certificate_budget,
-                    )?;
-                    ensure_leaf_certificate_matches_rsa_key(&certificates[0], &private_key)?;
-                }
-                Ok::<_, CommandError>(RecipientPrivateKey {
-                    inner: PrivateKeyDecryptor::new(private_key),
-                    key_name: option.parameter.clone(),
-                })
-            })();
+                    })
+                },
+            );
             match loaded {
                 Ok(key) => keys.push(key),
                 Err(error) if lax_key_search && lax_candidate_error_is_recoverable(&error) => {
@@ -4305,6 +4352,130 @@ mod tests {
         );
     }
 
+    #[test]
+    fn temporary_private_imports_keep_kdf_work_after_failure() {
+        use der::Encode as _;
+        use rsa::pkcs8::{
+            EncryptedPrivateKeyInfoRef,
+            pkcs5::{EncryptionScheme, pbes2},
+        };
+        // A failed decrypt spent work even though its temporary inventory held
+        // no key. A second PEM/DER candidate must see only the remainder.
+        let envelope = EncryptedPrivateKeyInfoRef {
+            encryption_algorithm: EncryptionScheme::Pbes2(pbes2::Parameters {
+                kdf: pbes2::Kdf::Pbkdf2(pbes2::Pbkdf2Params {
+                    salt: pbes2::Salt::new(b"12345678").unwrap(),
+                    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]).unwrap(),
+        };
+        let der = envelope.to_der().unwrap();
+        let pem = pem::encode(&pem::Pem::new("ENCRYPTED PRIVATE KEY", der.clone()));
+        let resources = xml_sec::policy::ResourcePolicy {
+            max_key_import_kdf_work: 5,
+            ..Default::default()
+        };
+        for (bytes, format) in [
+            (der.as_slice(), key_material::PrivateKeyFormat::Pkcs8Der),
+            (pem.as_bytes(), key_material::PrivateKeyFormat::Pkcs8Pem),
+        ] {
+            let mut budget =
+                ExternalMaterialBudget::new(resources.max_external_resource_total_bytes);
+            let import = |_: &mut ExternalMaterialBudget,
+                          inventory: &mut KeyInventory,
+                          remaining: &xml_sec::policy::ResourcePolicy| {
+                key_material::decode_rsa_private_with_inventory(
+                    Path::new("key"),
+                    bytes,
+                    format,
+                    Some(b"wrong"),
+                    remaining,
+                    inventory,
+                )
+                .map_err(CommandError::from)
+            };
+            assert!(matches!(
+                budget.with_key_import(&resources, import),
+                Err(CommandError::Key(
+                    key_material::KeyMaterialError::ProtectedContainer
+                ))
+            ));
+            assert_eq!(budget.kdf_work, 3);
+            assert!(matches!(
+                budget.with_key_import(&resources, import),
+                Err(CommandError::Key(key_material::KeyMaterialError::Policy(_)))
+            ));
+            assert_eq!(budget.kdf_work, 3, "denial must precede the second KDF");
+        }
+    }
+
+    #[test]
+    fn temporary_pkcs12_imports_keep_aggregate_work() {
+        // Lax candidates use temporary inventories without resetting operation work.
+        let bytes =
+            include_bytes!("../../../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12");
+        let resources = xml_sec::policy::ResourcePolicy {
+            max_key_import_kdf_work: 10_000,
+            ..Default::default()
+        };
+        let mut budget = ExternalMaterialBudget::new(resources.max_external_resource_total_bytes);
+        let import = |_: &mut ExternalMaterialBudget,
+                      inventory: &mut KeyInventory,
+                      remaining: &xml_sec::policy::ResourcePolicy| {
+            inventory
+                .add_pkcs12("key".into(), bytes, "secret", remaining)
+                .map_err(CommandError::from)
+        };
+        budget.with_key_import(&resources, import).unwrap();
+        assert!(budget.kdf_work > 0);
+        assert!(matches!(
+            budget.with_key_import(&resources, import),
+            Err(CommandError::KeyStore(key_manager::KeyStoreError::Policy(
+                _
+            )))
+        ));
+    }
+
+    #[test]
+    fn key_store_import_includes_prior_external_material() {
+        // Certificate buffers stay live while keys.xml and its decoded key coexist.
+        let temp = tempfile::tempdir().unwrap();
+        let path = temp.path().join("keys.xml");
+        let xml = "aAA==";
+        fs::write(&path, xml).unwrap();
+        let invocation = invocation(&[
+            "xmlsec1",
+            "verify",
+            "--keys-file",
+            path.to_str().unwrap(),
+            "input.xml",
+        ]);
+        let mut policy = xml_sec::policy::VerificationPolicy::default();
+        policy.resources.max_external_resource_total_bytes = xml.len() + 100;
+        let mut budget =
+            ExternalMaterialBudget::new(policy.resources.max_external_resource_total_bytes);
+        budget.charge(99).unwrap();
+        assert!(matches!(
+            load_xml_key_stores(&invocation, &policy, XmlBackend::default(), &mut budget),
+            Err(CommandError::KeyStore(key_manager::KeyStoreError::Policy(
+                _
+            )))
+        ));
+        let mut exact =
+            ExternalMaterialBudget::new(policy.resources.max_external_resource_total_bytes);
+        exact.charge(97).unwrap();
+        assert_eq!(
+            load_xml_key_stores(&invocation, &policy, XmlBackend::default(), &mut exact)
+                .unwrap()
+                .entry_count(),
+            1
+        );
+    }
+
     #[test]
     fn repeated_key_files_share_candidate_and_parser_budgets() {
         // The third entry must fail the operation budget before its malformed
diff --git a/tools/xmlsec1/src/key_material.rs b/tools/xmlsec1/src/key_material.rs
index f64d92bd..d3896574 100644
--- a/tools/xmlsec1/src/key_material.rs
+++ b/tools/xmlsec1/src/key_material.rs
@@ -993,10 +993,29 @@ pub fn decode_rsa_private_with_password(
     format: PrivateKeyFormat,
     password: Option<&[u8]>,
     resources: &ResourcePolicy,
+) -> Result {
+    decode_rsa_private_with_inventory(
+        path,
+        bytes,
+        format,
+        password,
+        resources,
+        &mut KeyInventory::default(),
+    )
+}
+
+/// Use the operation's import session so protected-key work is observable even
+/// when password validation or native RSA decoding fails.
+pub fn decode_rsa_private_with_inventory(
+    path: &Path,
+    bytes: &[u8],
+    format: PrivateKeyFormat,
+    password: Option<&[u8]>,
+    resources: &ResourcePolicy,
+    inventory: &mut KeyInventory,
 ) -> 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(),

From ace4f569c001631ba7607ad7c5dcd77aa96d825f Mon Sep 17 00:00:00 2001
From: Dmitry Prudnikov 
Date: Fri, 2 Oct 2026 16:15:47 +0300
Subject: [PATCH 9/9] fix(keys): preflight remaining import allowances

---
 docs/key-management.md |   5 +
 src/key_manager.rs     | 253 +++++++++++++++++++++++++++++++++++++----
 2 files changed, 238 insertions(+), 20 deletions(-)

diff --git a/docs/key-management.md b/docs/key-management.md
index c4b04af3..8dec6879 100644
--- a/docs/key-management.md
+++ b/docs/key-management.md
@@ -79,6 +79,9 @@ 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.
+Before constructing its owned resolver configuration, fallback accounts for both
+the original configured certificates/enabled CRLs and their simultaneously live
+copies, including document CRLs attached to a selected named certificate.
 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
@@ -114,6 +117,8 @@ resource-policy error without invoking the callback.
 `max_key_import_kdf_memory_bytes` can tighten protected-key derivation; both
 are capped by implementation safety ceilings and checked before decryption.
 KDF work accumulates across imports in one inventory, including failed decrypts.
+Encrypted PKCS#8 preflight uses the remaining work allowance before invoking a
+password callback; an exhausted allowance cannot prompt for another secret.
 The CLI carries that usage across temporary signing and decryption candidates,
 so lax search cannot multiply the operation allowance. Temporary inventories
 are released after extracting the candidate; accounting does not retain extra keys.
diff --git a/src/key_manager.rs b/src/key_manager.rs
index bde4afef..2c3ab822 100644
--- a/src/key_manager.rs
+++ b/src/key_manager.rs
@@ -600,16 +600,22 @@ impl<'a> KeyResolver for InventoryVerificationResolver<'a> {
                     }
                     .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()),
+                // The owned resolver config and its borrowed inventory/document
+                // input coexist. Reserve both payloads before any config clone;
+                // this is live-memory policy, not a certificate syntax rule.
+                for _ in 0..2 {
+                    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());
                     }
-                    .into());
+                    total += material.len();
                 }
-                total += material.len();
             }
             DefaultKeyResolver::new(KeyResolverConfig {
                 lookup_certs: self.inventory.lookup_certificates.clone(),
@@ -1430,16 +1436,9 @@ impl KeyInventory {
                         resources.max_external_resource_total_bytes,
                     )
                 })?;
-            enforce_pkcs8_kdf_policy(&encrypted, resources, kdf_live_bytes)?;
+            let remaining = self.remaining_kdf_resources(resources)?;
+            enforce_pkcs8_kdf_policy(&encrypted, &remaining, kdf_live_bytes)?;
             let password = password.ok_or(KeyStoreError::ProtectedContainer)?;
-            let mut remaining = resources.clone();
-            if self.kdf_work > remaining.max_key_import_kdf_work {
-                return Err(import_resource_limit(
-                    crate::policy::resource_name::KEY_IMPORT_KDF_WORK,
-                    resources.max_key_import_kdf_work,
-                ));
-            }
-            remaining.max_key_import_kdf_work -= self.kdf_work;
             let work =
                 enforce_pkcs8_password_policy(&encrypted, password, &remaining, kdf_live_bytes)?;
             self.kdf_work += work;
@@ -1531,7 +1530,10 @@ impl KeyInventory {
             self.check_material_capacity(named_material_length(&name, bytes.len(), 1)?, resources)?;
         let encrypted = EncryptedPrivateKeyInfoRef::try_from(bytes).ok();
         let secret = if let Some(encrypted) = &encrypted {
-            enforce_pkcs8_kdf_policy(encrypted, resources, retained_with_input)?;
+            let remaining = self.remaining_kdf_resources(resources)?;
+            // Secret acquisition is observable work: use the same remaining
+            // allowance as direct import before invoking the caller.
+            enforce_pkcs8_kdf_policy(encrypted, &remaining, retained_with_input)?;
             Some(password().ok_or(KeyStoreError::ProtectedContainer)?)
         } else {
             None
@@ -1557,7 +1559,7 @@ impl KeyInventory {
             enforce_pkcs8_password_policy(
                 encrypted,
                 secret,
-                resources,
+                &self.remaining_kdf_resources(resources)?,
                 retained_with_input + secret.capacity() - secret.len(),
             )?;
         }
@@ -1853,6 +1855,21 @@ impl KeyInventory {
         Ok(())
     }
 
+    fn remaining_kdf_resources(
+        &self,
+        resources: &ResourcePolicy,
+    ) -> Result {
+        if self.kdf_work > resources.max_key_import_kdf_work {
+            return Err(import_resource_limit(
+                crate::policy::resource_name::KEY_IMPORT_KDF_WORK,
+                resources.max_key_import_kdf_work,
+            ));
+        }
+        let mut remaining = resources.clone();
+        remaining.max_key_import_kdf_work -= self.kdf_work;
+        Ok(remaining)
+    }
+
     fn check_material_capacity(
         &self,
         length: usize,
@@ -4501,6 +4518,78 @@ mod tests {
         }
     }
 
+    #[test]
+    fn encrypted_pkcs8_callback_checks_remaining_work() {
+        use der::Encode as _;
+        use pkcs8::pkcs5::{EncryptionScheme, pbes2};
+        // Failed decryption consumes work. A later container must not request
+        // another secret when its visible derivation exceeds the remainder.
+        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 resources = ResourcePolicy {
+            max_key_import_kdf_work: 4,
+            ..ResourcePolicy::default()
+        };
+        let mut inventory = KeyInventory::default();
+        assert!(matches!(
+            inventory.add_private_der(
+                "first".into(),
+                &envelope,
+                Some(b"wrong"),
+                KeyUsages::SIGN,
+                &resources,
+            ),
+            Err(KeyStoreError::ProtectedContainer)
+        ));
+        assert_eq!(inventory.key_import_kdf_work(), 3);
+        let calls = std::cell::Cell::new(0);
+        let result = inventory.add_private_der_with_password_callback(
+            "second".into(),
+            &envelope,
+            || {
+                calls.set(calls.get() + 1);
+                Some(Zeroizing::new(b"wrong".to_vec()))
+            },
+            KeyUsages::SIGN,
+            &resources,
+        );
+        assert_eq!(calls.get(), 0, "preflight precedes secret acquisition");
+        assert!(matches!(result, Err(KeyStoreError::Policy(_))));
+        assert_eq!(inventory.key_import_kdf_work(), 3);
+        // An exact remaining derivation is allowed to request the password.
+        let exact = ResourcePolicy {
+            max_key_import_kdf_work: 6,
+            ..resources
+        };
+        assert!(matches!(
+            inventory.add_private_der_with_password_callback(
+                "third".into(),
+                &envelope,
+                || {
+                    calls.set(calls.get() + 1);
+                    Some(Zeroizing::new(b"wrong".to_vec()))
+                },
+                KeyUsages::SIGN,
+                &exact,
+            ),
+            Err(KeyStoreError::ProtectedContainer)
+        ));
+        assert_eq!(calls.get(), 1);
+        assert_eq!(inventory.key_import_kdf_work(), 6);
+    }
+
     #[test]
     fn encrypted_pkcs8_requires_correct_password_without_plaintext_fallback() {
         // Wrong passwords must not retry another format or leave a partial
@@ -6175,8 +6264,10 @@ mod tests {
             "{error}"
         );
         let mut bounded = policy.clone();
+        // The owned fallback coexists with the root and enabled document CRL.
+        // Deny one byte below that peak; disabling CRLs releases both charges.
         bounded.resources.max_external_resource_total_bytes =
-            leaf.der().len() + root.der().len() + crl.der().len() - 1;
+            leaf.der().len() + 2 * (root.der().len() + crl.der().len()) - 1;
         assert!(matches!(
             resolver.resolve_with_policy_and_provider(
                 Some(&info),
@@ -6530,6 +6621,128 @@ mod tests {
         );
     }
 
+    #[test]
+    fn configured_x509_fallback_budgets_live_copies() {
+        // Inventory bytes remain live while the owned fallback is assembled.
+        // Deny before cloning even malformed CRLs; admit the exact live peak.
+        let resources = ResourcePolicy::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();
+        for trusted in [false, true] {
+            for with_crl in [false, true] {
+                let mut inventory = KeyInventory::default();
+                inventory
+                    .add_certificate_der(certificate.clone(), trusted, &resources)
+                    .expect("certificate imports");
+                let document_crl = vec![0; 16];
+                if with_crl {
+                    inventory.crls.push(vec![0; 32]);
+                }
+                let info = KeyInfo {
+                    sources: vec![KeyInfoSource::X509Data(X509DataInfo {
+                        subject_names: vec!["CN=absent".into()],
+                        crls: if with_crl {
+                            vec![document_crl]
+                        } else {
+                            Vec::new()
+                        },
+                        ..X509DataInfo::default()
+                    })],
+                };
+                let mut policy = crate::policy::VerificationPolicy::default();
+                policy.key_trust.verify_x509_chains = true;
+                policy.key_trust.check_crls = with_crl;
+                let configured = certificate.len() + if with_crl { 32 } else { 0 };
+                // Document evidence is not copied into fallback for an unnamed
+                // source; the default resolver checks it with configured bytes.
+                let peak = 2 * configured;
+                policy.resources.max_external_resource_total_bytes = peak - 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::AGGREGATE_EXTERNAL_RESOURCE_BYTES,
+                                ..
+                            }
+                        ))
+                    ),
+                    "each owned fallback copy must fit before allocation"
+                );
+                policy.resources.max_external_resource_total_bytes = peak;
+                let resolver = inventory.verification_resolver();
+                let outcome = resolver.resolve_with_policy_and_provider(
+                    Some(&info),
+                    SignatureAlgorithm::RsaSha256,
+                    &policy,
+                    crate::provider::default_provider(),
+                );
+                assert!(
+                    !matches!(outcome, Err(DsigError::Policy(_))),
+                    "exact copy budget passes preflight"
+                );
+            }
+        }
+    }
+
+    #[test]
+    fn named_x509_fallback_budgets_document_crl_copies() {
+        // A named certificate substitutes document keys, not revocation
+        // evidence. Both retained document CRLs and fallback copies coexist.
+        let resources = ResourcePolicy::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 mut inventory = KeyInventory::default();
+        inventory
+            .add_public_der("leaf".into(), certificate.clone(), &resources)
+            .expect("named certificate");
+        let info = KeyInfo {
+            sources: vec![
+                KeyInfoSource::KeyName("leaf".into()),
+                KeyInfoSource::X509Data(X509DataInfo {
+                    crls: vec![vec![0; 32]],
+                    ..X509DataInfo::default()
+                }),
+            ],
+        };
+        let mut policy = crate::policy::VerificationPolicy::default();
+        policy.key_trust.verify_x509_chains = true;
+        policy.key_trust.check_crls = true;
+        policy.resources.max_external_resource_total_bytes = certificate.len() + 64 - 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::AGGREGATE_EXTERNAL_RESOURCE_BYTES,
+                    ..
+                }
+            ))
+        ));
+    }
+
     #[test]
     fn selected_certificate_and_anchors_share_aggregate_budget() {
         // Selected named material and configured trust material are one