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..4d64306d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -185,6 +185,18 @@ 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 encryption recipients are selected +from the same caller-owned inventory; an imported key never bypasses the +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 +[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. @@ -266,6 +278,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 new file mode 100644 index 00000000..8dec6879 --- /dev/null +++ b/docs/key-management.md @@ -0,0 +1,247 @@ +# 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, 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 +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. +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. +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. +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 +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. +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. +`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. +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. +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. +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. +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. +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. +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 +(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..738fda67 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,17 @@ 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. +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/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..2c3ab822 --- /dev/null +++ b/src/key_manager.rs @@ -0,0 +1,7584 @@ +//! Caller-owned, provider-neutral key inventory and xmlsec key-store import. + +use std::collections::HashSet; + +#[cfg(test)] +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; +use rsa::{ + 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; + +#[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, 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, + kdf_work: 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, + 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, + }) + } + + /// 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 + .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()) + } + + /// 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()); + } + // 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()); + } + 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(KeyValueInfo), + 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]) + } + + 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)] +/// 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 { + /// 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> { + 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, 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) + .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.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); + 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. + /// 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, + 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 = 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, + 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" => { + 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"))?; + // 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")), + }; + 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)?; + // 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. + // 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, + ) + })?; + let remaining = self.remaining_kdf_resources(resources)?; + enforce_pkcs8_kdf_policy(&encrypted, &remaining, kdf_live_bytes)?; + let password = password.ok_or(KeyStoreError::ProtectedContainer)?; + 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()); + 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 { + use der::Encode as _; + preflight_rsa_pkcs1_components(bytes)?; + // 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) + }; + if der.len() > resources.max_external_resource_bytes { + return Err(import_resource_limit( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + resources.max_external_resource_bytes, + )); + } + let 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, + )?; + 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 { + 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 + }; + 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, + &self.remaining_kdf_resources(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 = 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, + } + let payload = match block.label { + "PRIVATE KEY" => Payload::Plain, + "ENCRYPTED PRIVATE KEY" => Payload::Encrypted, + "RSA PRIVATE KEY" => Payload::Rsa, + _ => return Err(KeyStoreError::Selection("unsupported private PEM label")), + }; + 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 + 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)?, + } + 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_with_work(bytes, &limits, self.kdf_work)?; + let secret = password().ok_or(KeyStoreError::ProtectedContainer)?; + let contents = + prepared.decrypt_with_work(&secret, secret.capacity(), &mut self.kdf_work)?; + 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_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) + } + + 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 identity = PrivateKeyIdentity::decode(private_key.as_ref())?; + if usages.allows(KeyUsage::Decrypt) && !matches!(identity, PrivateKeyIdentity::Rsa(_)) { + 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 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", + )); + } + // 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()); + } + } + 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(); + 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 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, + 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 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; + 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; + 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()) { + 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, decode_xml_base64_secret(key)?) + } else if key.has_tag_name((XMLDSIG_NS, "DSAKeyValue")) { + 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( + crate::xmldsig::parse::parse_key_value_dispatch(child) + .map_err(|error| KeyStoreError::Invalid(error.to_string()))?, + ) + } 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(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() + .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() + .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, + 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")) +} + +// 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], + } + } +} + +/// 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(); + 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(()) +} + +// 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 BorrowedPem<'a> { + label: &'a str, + data: &'a str, + decoded_len: usize, +} + +impl<'a> BorrowedPem<'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)?; + let (payload, end) = rest + .trim_start_matches([' ', '\t', '\n', '\r']) + .split_once("-----END ") + .ok_or_else(invalid)?; + 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> { + 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(), + current: &[], + }; + let mut decoder = + base64::read::DecoderReader::new(input, &base64::engine::general_purpose::STANDARD); + decoder + .read_exact(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(()) + } +} + +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) + } +} + +#[cfg(test)] +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 { + 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)?; + let work = 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, + )); + } + 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, + )?, + _ => return Err(KeyStoreError::ProtectedContainer), + }; + Ok(work) +} + +fn enforce_pkcs8_password_policy( + encrypted: &EncryptedPrivateKeyInfoRef<'_>, + password: &[u8], + resources: &ResourcePolicy, + live_bytes: usize, +) -> Result { + 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(|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( + n: u64, + r: u64, + p: u64, + resources: &ResourcePolicy, + retained_with_input: usize, +) -> Result { + 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, + )); + } + // 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( + 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 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())); + } + if child.is_text() { + text.push_str(child.text().unwrap_or_default()); + } + } + Ok(text) +} + +fn decode_xml_base64(node: Node<'_, '_>) -> Result, KeyStoreError> { + 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<'_, '_>, + budget: &mut XmlImportMaterialBudget, +) -> 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(decode_xml_base64_secret(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()))?; + use der::Encode as _; + // The native encoder owns parameter DER, padded X bytes, X DER and + // PKCS#8 DER concurrently; the retained copy also exists before return. + let parameters_len = + usize::try_from(private.verifying_key().components().encoded_len().map_err( + |_| KeyStoreError::Invalid("DSA private key encoding failed".into()), + )?) + .map_err(|_| KeyStoreError::Selection("key material size overflow"))?; + let x_bytes_len = usize::try_from(private.x().bits_precision() / 8) + .map_err(|_| KeyStoreError::Selection("key material size overflow"))?; + // DER length headers and the PKCS#8 algorithm identifier fit this + // checked bound: each header is at most 1 tag + 1 count + sizeof(usize). + let header = 2 + std::mem::size_of::(); + 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 + }; + Ok((KeyValueInfo::Dsa { p, q, g, y }, private)) +} + +#[cfg(test)] +mod tests { + use rand_chacha::{ChaCha8Rng, rand_core::SeedableRng as _}; + use rsa::RsaPublicKey; + use rsa::pkcs1::EncodeRsaPrivateKey as _; + use rsa::pkcs8::EncodePublicKey as _; + + use super::*; + + fn xml_policy(resources: ResourcePolicy) -> crate::policy::VerificationPolicy { + crate::policy::VerificationPolicy { + resources, + ..crate::policy::VerificationPolicy::default() + } + } + + #[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 + // 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() + 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 { + // Source, decoded byte, inventory name, and duplicate-name copy. + max_external_resource_total_bytes: valid.len() + 3, + ..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_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 + // 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 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 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 + // contract; malformed framing, alphabet and padding remain rejected. + for data in [ + "AQID", + "A Q\tI\r\nD", + "A\u{2003}QID", + "Header: value\n\nAQID", + "Header: value\r\n\r\nAQID", + ] { + 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 = BorrowedPem::parse(text.as_bytes(), 4096).expect("borrowed frame"); + assert_eq!( + frame.decode().expect("borrowed decode"), + expected.contents() + ); + } + for data in ["AQI=", "AQ==", "AQID"] { + let text = + format!("-----BEGIN RSA PUBLIC KEY-----\n{data}\n-----END RSA PUBLIC KEY-----"); + 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; + // whitespace-separated input must not truncate a multi-read payload. + 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 = BorrowedPem::parse(encoded.as_bytes(), 8192).expect("frame"); + assert_eq!(frame.decoded_len, length); + assert_eq!(frame.decode().expect("streamed decoding"), bytes); + } + for text in [ + "-----BEGIN PUBLIC KEY-----\nAQID\n-----END RSA PUBLIC KEY-----", + "-----BEGIN PUBLIC KEY-----\nAQID\n-----END PUBLIC KEY-----junk", + "-----BEGIN PUBLIC KEY-----\nAQID\n-----END PUBLIC KEY-----\n-----BEGIN PUBLIC KEY-----\nAQID\n-----END PUBLIC KEY-----", + "-----BEGIN PUBLIC KEY-----\nAQI\n-----END PUBLIC KEY-----", + "-----BEGIN PUBLIC KEY-----\nAQ$=\n-----END PUBLIC KEY-----", + "-----BEGIN PUBLIC KEY-----\nAR==\n-----END PUBLIC KEY-----", + "-----BEGIN PUBLIC KEY-----\nA===\n-----END PUBLIC KEY-----", + "-----BEGIN PUBLIC KEY-----\nAQ=I\n-----END PUBLIC KEY-----", + "-----BEGIN PUBLIC KEY-----\nnot a header\n\nAQID\n-----END PUBLIC KEY-----", + ] { + assert!( + BorrowedPem::parse(text.as_bytes(), 4096) + .and_then(|frame| frame.decode()) + .is_err(), + "accepted {text}" + ); + } + } + + #[test] + fn public_pem_import_accounts_for_simultaneous_buffers() { + use rsa::pkcs1::EncodeRsaPublicKey as _; + use rsa::pkcs8::DecodePublicKey as _; + // Caller PEM, decoded bytes and a normalized SPKI coexist; neither + // prior inventory material nor any intermediate may reuse that budget. + let pem = include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"); + let spki = pem::parse(pem).expect("public PEM fixture").into_contents(); + let key = RsaPublicKey::from_public_key_der(&spki).expect("RSA key"); + let pkcs1 = key.to_pkcs1_der().expect("PKCS1 encoding"); + for (label, decoded, extra) in [ + ("PUBLIC KEY", spki.as_slice(), 0), + ("RSA PUBLIC KEY", pkcs1.as_bytes(), spki.len()), + ] { + let encoded = pem::encode(&pem::Pem::new(label, decoded.to_vec())); + for exact in [false, true] { + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "existing".into(), + SymmetricKeyKind::Hmac, + vec![1; 16], + KeyUsages::SIGN, + &ResourcePolicy::default(), + ) + .expect("existing key"); + let retained = inventory.material_bytes; + let resources = ResourcePolicy { + max_external_resource_total_bytes: retained + + 2 * "new".len() + + encoded.len() + + decoded.len() + + extra + - usize::from(!exact), + ..ResourcePolicy::default() + }; + let result = inventory.add_public_pem("new".into(), encoded.as_bytes(), &resources); + if exact { + result.expect("exact peak allowance"); + assert_eq!(inventory.entry_count(), 2); + } else { + assert!(matches!(result, Err(KeyStoreError::Policy(_)))); + assert_eq!(inventory.entry_count(), 1); + assert_eq!(inventory.material_bytes, retained); + } + } + } + } + + #[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 _; + 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)); + // Public imports need one live DER; private imports need two, + // in addition to encoded input. Retaining the first input charge + // still makes the second padded import exceed this exact peak. + let workspace = pem::parse(pem).expect("PEM payload").contents().len() + * if is_private { 2 } else { 1 }; + let resources = ResourcePolicy { + max_external_resource_bytes: padded.len(), + max_external_resource_total_bytes: padded.len() + + if is_private { 5 } else { 10 } + + workspace + + 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"); + #[cfg(feature = "xmlenc")] + { + 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(); + // 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() + 2 * (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 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 + // 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_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() { + // 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..dac159de --- /dev/null +++ b/src/key_manager/pkcs12_import.rs @@ -0,0 +1,2043 @@ +//! 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 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/rfc9879#section-6 + 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, +} + +#[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, + &mut budget, + None, + &mut Contents { + private_keys: Vec::new(), + certificates: Vec::new(), + }, + )?; + Ok(Prepared { pfx, limits }) +} + +impl Prepared<'_, '_> { + #[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); + 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 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 + } +} + +#[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 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 + // 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/parse.rs b/src/xmldsig/parse.rs index a1b41fe7..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; @@ -752,6 +749,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, @@ -787,15 +825,32 @@ pub(crate) fn parse_key_info_with_policy_budgets_and_document_base( resources: &crate::policy::ResourcePolicy, document_base: Option<&str>, ) -> Result { - verify_ds_element(key_info_node, "KeyInfo")?; - ensure_no_non_whitespace_text(key_info_node, "KeyInfo")?; + 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 { + validate_key_info_container(key_info_node)?; 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 +865,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 +925,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)); @@ -1060,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")?; @@ -1090,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")?; @@ -1276,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( @@ -1334,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); @@ -1358,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) => { @@ -2077,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 { @@ -2649,6 +2704,115 @@ 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 + // 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/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/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"); 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..e6c011ed 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -85,6 +85,28 @@ 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. + /// + /// 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, + algorithm: DataEncryptionAlgorithm, + encrypted_key: Option<&EncryptedKey>, + policy: &crate::policy::DecryptionPolicy, + budget: &mut KeyCandidateBudget, + ) -> Result>, XmlEncError> { + policy.validate()?; + 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`. fn resolve_key( &self, @@ -507,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, @@ -572,13 +608,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 +969,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 +989,7 @@ fn resolve_content_key_candidates( provider, algorithm, Some(encrypted_key), + policy, budget, ) { Ok(keys) => candidates.extend(keys), @@ -938,10 +1022,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 +1284,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 { @@ -1344,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 @@ -1406,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, @@ -1421,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, @@ -1467,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()]); @@ -1493,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()]), @@ -1545,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 { @@ -1577,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 @@ -1659,6 +1850,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 } @@ -1823,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, @@ -1851,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, 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/capability_ledger.rs b/tests/capability_ledger.rs index ebb2e42d..a5215592 100644 --- a/tests/capability_ledger.rs +++ b/tests/capability_ledger.rs @@ -1046,4 +1046,5 @@ fn deprecated_surface_is_explicitly_unsupported() { .all(|item| classification(&ledger, item).outcome == "intentionally-unsupported") ); } +#[cfg(all(feature = "xmldsig", feature = "xmlenc"))] use xml_sec as roxmltree; 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..2a39fa64 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,67 @@ 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::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)?; + 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 +784,82 @@ fn sign(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), CommandEr &policy, xml_backend, )?; - let selected = select_signing_key( - invocation, - &signature.key_names, - signature.algorithm, - signature.key_info.as_ref(), - &policy, - password, - )?; + let has_key_store = invocation.values("keys-file").next().is_some(); + if has_key_store + && invocation + .ordered_values(&[ + "hmac-key", + "privkey-pem", + "privkey-der", + "pkcs8-pem", + "pkcs8-der", + "pkcs12", + ]) + .next() + .is_some() + { + return Err(CommandError::Usage( + "sign cannot combine --keys-file with explicit key options".into(), + )); + } + let selected = if has_key_store { + if signature.key_names.is_empty() && !invocation.flag("lax-key-search") { + return Err(CommandError::Usage( + "sign with --keys-file requires a template KeyName unless --lax-key-search is set" + .into(), + )); + } + let mut budget = + ExternalMaterialBudget::new(policy.resources.max_external_resource_total_bytes); + let store = load_xml_key_stores(invocation, &policy, xml_backend, &mut budget)?; + let lax_candidates = invocation.flag("lax-key-search"); + let candidates = if signature.algorithm.hmac_output_bits().is_some() { + select_store_candidates( + store.symmetric_keys().iter().filter(|entry| { + entry.kind == SymmetricKeyKind::Hmac + && entry.usages.allows(key_manager::KeyUsage::Sign) + }), + &signature.key_names, + lax_candidates, + policy.resources.max_key_candidates, + |entry| &entry.name, + )? + .into_iter() + .map(|entry| entry.name.as_str()) + .collect::>() + } else { + select_store_candidates( + store + .private_keys() + .iter() + .filter(|entry| entry.usages.allows(key_manager::KeyUsage::Sign)), + &signature.key_names, + lax_candidates, + policy.resources.max_key_candidates, + |entry| &entry.name, + )? + .into_iter() + .map(|entry| entry.name.as_str()) + .collect::>() + }; + select_store_signing_key( + &store, + candidates, + signature.algorithm, + signature.key_info.as_ref(), + &policy, + lax_candidates, + )? + } else { + select_signing_key( + invocation, + &signature.key_names, + signature.algorithm, + signature.key_info.as_ref(), + &policy, + password, + )? + }; let mut context = SignContext::new(selected.key.as_ref()) .policy(policy) .xml_backend(xml_backend) @@ -801,6 +940,40 @@ struct SigningKeyCandidate { leaf_certificate_der: Option>, } +fn select_store_signing_key<'a>( + store: &KeyInventory, + candidates: impl IntoIterator, + algorithm: SignatureAlgorithm, + key_info: Option<&KeyInfo>, + policy: &SigningPolicy, + lax: bool, +) -> Result { + let mut last_error = None; + let mut lookup_budget = key_manager::SigningLookupBudget::default(); + for name in candidates { + let attempt = store + .signing_key_with_budget(name, algorithm, policy, &mut lookup_budget) + .map_err(CommandError::from) + .and_then(|key| { + let candidate = SigningKeyCandidate { + key, + certificate_writer: None, + leaf_certificate_der: None, + }; + validate_signing_key_info(key_info, &candidate)?; + Ok(candidate) + }); + match attempt { + Ok(candidate) => return Ok(candidate), + Err(error) if lax && lax_candidate_error_is_recoverable(&error) => { + last_error = Some(error); + } + Err(error) => return Err(error), + } + } + Err(last_error.unwrap_or_else(|| CommandError::Usage("no compatible signing key".into()))) +} + fn select_signing_key( invocation: &Invocation, requested_names: &[String], @@ -813,7 +986,13 @@ fn select_signing_key( let key_options: &[&str] = if hmac { &["hmac-key"] } else { - &["privkey-pem", "privkey-der", "pkcs8-pem", "pkcs8-der"] + &[ + "privkey-pem", + "privkey-der", + "pkcs8-pem", + "pkcs8-der", + "pkcs12", + ] }; let key_kind = if hmac { "HMAC key" } else { "private key" }; let keys = invocation @@ -824,7 +1003,7 @@ fn select_signing_key( return Err(CommandError::Usage(if hmac { "HMAC signing requires --hmac-key".into() } else { - "sign requires --privkey-pem or --pkcs8-pem/der".into() + "sign requires --privkey-pem, --pkcs8-pem/der, or --pkcs12".into() })); } let candidates = named_candidate_search( @@ -876,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(); @@ -891,17 +1086,71 @@ 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 name = option.parameter.clone().unwrap_or_else(|| "pkcs12".into()); + inventory.add_pkcs12(name.clone(), &bytes, password, 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 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, + resources, + )?; + } + key_material::PrivateKeyFormat::Der | key_material::PrivateKeyFormat::Pkcs8Der => { + inventory.add_private_der( + name.into(), + &key_bytes, + password, + key_manager::KeyUsages::SIGN, + 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 +1423,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 +1437,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 +1473,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 +1527,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); @@ -1288,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)] @@ -1350,6 +1667,7 @@ impl ExternalMaterialBudget { Self { total_bytes: 0, maximum_bytes, + kdf_work: 0, } } @@ -1363,12 +1681,75 @@ impl ExternalMaterialBudget { })?; Ok(()) } + + 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( + 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::PrivateKeyComponents(_)) + | CommandError::Key(key_material::KeyMaterialError::Policy(_)) + ) } fn push_configured_certificate(certificates: &mut Vec>, certificate: Vec) { @@ -1559,6 +1940,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 +1949,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 +1964,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 +2007,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 +2174,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 +2235,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 +2733,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), @@ -2216,11 +2850,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())) }) @@ -2393,6 +3029,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 +3110,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 +3272,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 +3473,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 +3485,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 +3526,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 +3544,52 @@ 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) + }) + { + // 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: RSAKeyValue imports are public-only; use --privkey-pem, --privkey-der, or --pkcs12".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, @@ -2872,38 +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 = (|| { - 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( - Path::new(path), - &bytes, - private_key_format(option), - )?; - if !certificate_paths.is_empty() { - let encoding = if matches!( + 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), - 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, + password, + resources, + inventory, )?; - ensure_leaf_certificate_matches_rsa_key(&certificates[0], &private_key)?; - } - Ok::<_, CommandError>(RecipientPrivateKey { - inner: PrivateKeyDecryptor::new(private_key), - key_name: option.parameter.clone(), - }) - })(); + 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(), + }) + }, + ); match loaded { Ok(key) => keys.push(key), Err(error) if lax_key_search && lax_candidate_error_is_recoverable(&error) => { @@ -2934,7 +3695,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 +3768,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 +3794,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 +3824,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 +3838,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 +3863,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 +4303,344 @@ 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 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 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 + // 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::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 { + 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 +4687,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 +5158,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 +5531,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..d3896574 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,10 @@ pub enum KeyMaterialError { InvalidPem(PathBuf), #[error("unsupported private key in {}", .0.display())] 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())] @@ -115,23 +120,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 +398,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 +528,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 +563,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 +605,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 +617,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 +668,58 @@ 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())) + 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, +} + +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 +727,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 +748,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 +762,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 +817,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,24 +976,99 @@ 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 { - 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::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(), + 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 { + 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 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())); + } + 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) { + 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::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())) } @@ -1021,6 +1178,180 @@ 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 + // 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..f69522e5 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,406 @@ 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") + ); + // 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" + ) + ); - 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 +3811,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 +3835,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 +6339,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