From 715eb9feca26ff6de4d7c5faed31aee347cb0a5f Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 30 Sep 2026 17:43:06 +0300 Subject: [PATCH 1/6] feat(keys): add caller-owned key inventory Add bounded key-store imports and CLI key selection across signing, verification, encryption, and decryption. Enforce policy before protected-key callbacks, preserve typed failures, and cover integration and negative paths. Closes #167 --- .github/workflows/ci.yml | 2 +- Cargo.toml | 6 + README.md | 3 + docs/cli.md | 10 + docs/key-management.md | 108 + src/document.rs | 23 +- src/hard_limits.rs | 8 + src/key_manager.rs | 4549 +++++++++++++++++ src/lib.rs | 2 + src/policy.rs | 34 + src/xmldsig/keys.rs | 491 +- src/xmldsig/sign.rs | 51 +- src/xmldsig/x509.rs | 38 +- tests/donor_interop_suite.rs | 8 +- tests/fixtures/keys/pkcs12/ec-key.p12.b64 | 1 + .../keys/pkcs12/rsa-duplicate-leaf.p12.b64 | 1 + .../keys/pkcs12/rsa-key-unrelated-ca.p12.b64 | 1 + tests/fixtures/keys/xmlsec/mixed-keys.xml | 52 + tests/fixtures_smoke.rs | 2 +- tests/key_manager_feature_contract.rs | 11 + tests/xmlenc_encrypt_xmlsec1.rs | 40 + tools/xmlsec1/src/args.rs | 1 + tools/xmlsec1/src/commands.rs | 1049 +++- tools/xmlsec1/src/key_material.rs | 161 +- tools/xmlsec1/tests/process_contract.rs | 782 +++ 25 files changed, 7331 insertions(+), 103 deletions(-) create mode 100644 docs/key-management.md create mode 100644 src/key_manager.rs create mode 100644 tests/fixtures/keys/pkcs12/ec-key.p12.b64 create mode 100644 tests/fixtures/keys/pkcs12/rsa-duplicate-leaf.p12.b64 create mode 100644 tests/fixtures/keys/pkcs12/rsa-key-unrelated-ca.p12.b64 create mode 100644 tests/fixtures/keys/xmlsec/mixed-keys.xml create mode 100644 tests/key_manager_feature_contract.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c5c92c1..6adbc27e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,7 +71,7 @@ jobs: xml-backend: fat-runtime cargo-args: --no-default-features --features xmldsig,xmlenc,c14n,xml-backends-all - rust: stable - xml-backend: xmlenc-only + xml-backend: xmlenc-inventory cargo-args: --no-default-features --features xmlenc,xml-backend-xmloxide - rust: "1.92.0" xml-backend: xmloxide diff --git a/Cargo.toml b/Cargo.toml index f1918003..7f06f44b 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 } +ribergshamra-core = { version = "0.11", default-features = false, optional = true } +ribergshamra-pkcs12 = { version = "0.11", default-features = false, features = ["rustcrypto", "legacy-algorithms"], optional = true } # X.509 certificates x509-parser = { version = "0.18", features = ["verify"], optional = true } @@ -152,6 +154,8 @@ xmldsig = [ # XML Digital Signatures (sign + verify) "dep:hmac", "dep:md-5", "dep:pem", + "dep:ribergshamra-core", + "dep:ribergshamra-pkcs12", "dep:peresil", "dep:p256", "dep:p384", @@ -171,6 +175,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..c71b3432 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 PKCS#12 import | | SAML 2.0 | Signed assertions and encrypted-assertion workflows covered by integration tests | | XML input | Strict bounded byte decoding, entity/depth/node limits, stable node identities, and generation-safe mutation | | Crypto | Provider-neutral contracts and opaque key handles with pure-Rust RustCrypto as the default implementation | @@ -83,6 +84,8 @@ The signing and verification pipelines support same-document and caller-provided XPath 1.0 and XPath Filter 2 transforms, `Manifest`, structured `KeyInfo`, and policy-controlled X.509 validation. See [XML Digital Signatures](docs/xmldsig.md) for algorithms, transform semantics, key resolution, failure handling, and current interoperability boundaries. +See [Key management](docs/key-management.md) for inventory ownership, format import, +password handling, and CLI key-store behavior. ## XML Encryption diff --git a/docs/cli.md b/docs/cli.md index 43693f7a..1c217617 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -185,6 +185,16 @@ headers, or DER structure select encrypted versus plain decoding first: a supplied password is ignored for a plain key, while a missing or wrong password for an encrypted key fails without a plaintext fallback, before output is committed, and is never included in diagnostics. +`--keys-file FILE` imports a bounded xmlsec `keys.xml` store for sign, verify, +encrypt, and decrypt. Named HMAC/DSA signers, named HMAC/RSA/EC public +verification keys, direct AES content keys, and RSA-OAEP recipients are selected +from the same caller-owned inventory; an imported key never bypasses the +operation policy. `--pkcs12[:NAME] FILE --pwd PASSWORD` supplies one private +key and its certificate chain for sign or RSA decryption. Bundles with multiple +private keys are rejected rather than selecting an arbitrary bag. Neither +option triggers network lookup or implicit key discovery. See +[Key management](key-management.md) for the byte-oriented library API and trust +model. Verification accepts `-` as the conventional stdin marker. Verification starts at the document root and uses the first descendant `Signature` in document order. diff --git a/docs/key-management.md b/docs/key-management.md new file mode 100644 index 00000000..0f1e0ed6 --- /dev/null +++ b/docs/key-management.md @@ -0,0 +1,108 @@ +# Key management + +`xml_sec::key_manager::KeyInventory` is a caller-owned inventory of named key +material. The library imports bytes supplied by the caller; it never discovers +files, reads the environment, or fetches network resources. Applications keep +the inventory for as long as its keys are needed and pass the selected signer or +resolver to the normal XMLDSig/XMLEnc operation context. Import and execution +are both bounded by the operation's `ResourcePolicy` and cryptographic policy. +`KeyInventory::from_xml_bytes` accepts the same signing, verification, +encryption, or decryption policy snapshot used by the operation, so XML parser +allowances and resource limits cannot diverge. `decryption_resolver` also +requires the decryption snapshot and checks selected key material before +copying or decoding it. A permitted document `KeyName` may select a caller-owned +public key even when document-supplied key bytes are disabled; all sources in +the document's original `KeyInfo` remain subject to the source policy. +The `xmlenc` Cargo feature also enables `xmldsig`: the shared inventory uses +XMLDSig `KeyInfo` to represent named public recipients. Thus the inventory API +is available when an application selects `xmlenc` and an XML backend without +separately naming `xmldsig`. + +The inventory accepts raw HMAC and AES secrets, public SPKI DER or PEM (also +PKCS#1 RSA public PEM), private PKCS#8 DER or PEM (including password-protected +PKCS#8), RSA PKCS#1 private DER or PEM, PKCS#12 bundles, DER X.509 certificates +and CRLs, and libxmlsec1 `keys.xml` bytes. The `keys.xml` importer recognizes +HMAC, AES, RSA, EC, and libxmlsec1's private DSA extension. DES key entries +are rejected because this build has no DES encryption operation. Unknown +algorithms in a mixed xmlsec key store are skipped; malformed supported entries +and ambiguous names fail. A PKCS#12 bundle with more than one private key is +rejected rather than assigning arbitrary aliases. A matching leaf certificate +is retained with its imported private key; byte-identical duplicate leaf bags +count as one certificate. Other certificates are retained as +untrusted chain material. `matching_certificate_chain()` returns a chain only +when its first certificate matches the private key; a CA-only PKCS#12 bundle +can still sign without emitting an unrelated signing certificate. A private +bundle's certificates do not become verification lookup candidates implicitly; +register a certificate explicitly for lookup or trust when needed. + +Each imported key has an explicit `KeyUsages` set. For example, a key registered +for `Verify` cannot sign, and an `Encrypt`-only key cannot decrypt. Imported +public and PKCS#12 keys can be restricted at import with +`add_public_der_with_usages`, `add_public_pem_with_usages`, and +`add_pkcs12_with_usages`; the shorter methods authorize only operations +supported by that key family. An EC/DSA private key may sign but cannot be +assigned RSA decryption usage. Incompatible or empty usage sets are rejected. +EC and DSA public keys can verify but cannot be authorized as RSA encryption +recipients, including when imported from `keys.xml`. Imported certificates +are lookup candidates, **not trust anchors**, unless the caller +explicitly registers them as trusted. The operation's immutable policy still +decides algorithm acceptance, key minima, certificate validation, CRL checks, +and resource limits. An imported key is never permission to bypass that policy. +Caller-provided key names are bounded before import and charged to the retained +material budget for every stored copy, including public-key `KeyName` metadata. +Selection methods return `KeyStoreError::Policy` for operation-policy denials, +distinct from candidate-local `KeyStoreError::Selection` failures. Callers +must not retry another key after a policy rejection. +An already-selected public entry can expose its RSA recipient key directly via +`StoredPublicKey::rsa_encryption_key(&encryption_policy)` without a second +inventory name lookup. The operation policy is required so source sizes are +checked before RSA decoding. Direct and XML key-store imports accept only +16-, 24-, or 32-byte AES keys; unsupported public-key algorithms are rejected +at import rather than acquiring verification permission. + +`add_private_der_with_password_callback` asks the caller for a zeroizing byte +password only for encrypted PKCS#8; plaintext input does not invoke it. +`add_pkcs12_with_password_callback` obtains a zeroizing string password before +decoding the bundle, after checking the encoded size and outer MAC KDF +parameters. Other KDF parameters are checked during full decoding. A missing +or wrong password returns a redacted error and never +triggers an unprotected fallback. Oversized encoded bundles return a typed +resource-policy error without invoking the callback. +`ResourcePolicy::max_key_import_kdf_work` and +`max_key_import_kdf_memory_bytes` can tighten protected-key derivation; both +are capped by implementation safety ceilings and checked before decryption. +Exceeding a recognized KDF's work or memory limit returns a policy error; +missing or incorrect passwords remain protected-container errors. +When the PKCS#12 parser rejects an oversized salt, that distinct resource +rejection also returns a typed policy error. + +```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 production applications, do not put passwords on a process command line: +load them through the application's secret channel and call the byte-oriented +library import API instead. diff --git a/src/document.rs b/src/document.rs index e560b0e6..8632b638 100644 --- a/src/document.rs +++ b/src/document.rs @@ -5,6 +5,7 @@ //! generation atomically, so identities from an older generation cannot be //! confused with nodes in the new tree. +use std::borrow::Cow; #[cfg(any(feature = "xmldsig", feature = "xmlenc"))] use std::cell::Cell; use std::collections::{HashMap, HashSet, hash_map::Entry}; @@ -3303,6 +3304,14 @@ fn decode_owned_xml( maximum: usize, budget: Option<&XmlParseWorkBudget>, ) -> Result { + decode_xml_with_budget(bytes, maximum, budget).map(Cow::into_owned) +} + +pub(crate) fn decode_xml_with_budget<'a>( + bytes: &'a [u8], + maximum: usize, + budget: Option<&XmlParseWorkBudget>, +) -> Result, XmlDocumentError> { if bytes.len() > maximum { return Err(XmlDocumentError::DocumentTooLarge { maximum, @@ -3313,14 +3322,12 @@ fn decode_owned_xml( // Charge it before encoding detection/transcoding and retain that charge // in the same sticky budget used by preflight and semantic construction. charge_parse_work(budget, bytes.len())?; - xml_sec_xml_input::decode_xml_bounded(bytes, None, maximum) - .map(|xml| xml.into_owned()) - .map_err(|error| match error { - xml_sec_xml_input::Error::DecodedLimit { actual, .. } => { - XmlDocumentError::DocumentTooLarge { maximum, actual } - } - error => XmlDocumentError::Encoding(error), - }) + xml_sec_xml_input::decode_xml_bounded(bytes, None, maximum).map_err(|error| match error { + xml_sec_xml_input::Error::DecodedLimit { actual, .. } => { + XmlDocumentError::DocumentTooLarge { maximum, actual } + } + error => XmlDocumentError::Encoding(error), + }) } fn allocate_document_identity(counter: &AtomicU64) -> Result { diff --git a/src/hard_limits.rs b/src/hard_limits.rs index de360c22..8ba897cb 100644 --- a/src/hard_limits.rs +++ b/src/hard_limits.rs @@ -56,6 +56,14 @@ 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 memory reserved by one imported scrypt key derivation. +#[cfg(any(feature = "xmldsig", feature = "xmlenc"))] +pub(crate) const KEY_IMPORT_KDF_MEMORY_CEILING: usize = 32 * 1024 * 1024; +/// 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..12e53b27 --- /dev/null +++ b/src/key_manager.rs @@ -0,0 +1,4549 @@ +//! Caller-owned, provider-neutral key inventory and xmlsec key-store import. + +use std::collections::HashSet; + +use base64::Engine as _; +use crypto_bigint::{ + BoxedUint, + modular::{BoxedMontyForm, BoxedMontyParams}, +}; +use der::Decode as _; +use dsa::{ + Components as DsaComponents, SigningKey as NativeDsaSigningKey, + VerifyingKey as DsaVerifyingKey, pkcs8::EncodePrivateKey as _, +}; +use ribergshamra_pkcs12::{Pkcs12Limits, parse_pkcs12_with_limits}; +#[cfg(feature = "xmlenc")] +use rsa::pkcs8::DecodePublicKey as _; +use rsa::{ + RsaPrivateKey, RsaPublicKey, + pkcs1::{DecodeRsaPrivateKey as _, DecodeRsaPublicKey as _}, + pkcs8::{ + DecodePrivateKey as _, EncodePublicKey as _, EncryptedPrivateKeyInfoRef, PrivateKeyInfoRef, + }, +}; +use x509_parser::prelude::{FromDer as _, X509Certificate}; +use zeroize::Zeroizing; + +#[cfg(feature = "xmlenc")] +use crate::xmldsig::parse::X509PublicKeyInfo; +use crate::{ + XmlBackend, XmlDomNode as Node, + document::{ + DocumentParseSettings, XmlParseWorkBudget, parse_borrowed_with_settings_and_budget, + }, + policy::ResourcePolicy, + xmldsig::keys::InspectedKeyCandidateBudget, + xmldsig::parse::XMLDSIG11_NS, + xmldsig::{ + DefaultKeyResolver, DsaSigningKey, DsigError, EcdsaP256SigningKey, EcdsaP384SigningKey, + EcdsaP521SigningKey, HmacSigningKey, HmacVerificationKey, KeyInfo, KeyInfoSource, + KeyResolver, KeyResolverConfig, KeyValueInfo, RsaSigningKey, SignatureAlgorithm, + SigningKey, VerifyingKey, X509DataInfo, parse_key_info, validate_signing_key, + }, +}; + +const XMLSEC_NS: &str = "http://www.aleksey.com/xmlsec/2002"; +const XMLDSIG_NS: &str = "http://www.w3.org/2000/09/xmldsig#"; + +fn check_selected_public_material( + info: &KeyInfo, + resources: &ResourcePolicy, +) -> Result { + let mut total = 0_usize; + for source in &info.sources { + let mut charge = |length: usize| -> Result<(), DsigError> { + if length > resources.max_external_resource_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_bytes, + actual: length, + } + .into()); + } + total = total.checked_add(length).ok_or({ + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_total_bytes, + actual: usize::MAX, + } + })?; + if total > resources.max_external_resource_total_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_total_bytes, + actual: total, + } + .into()); + } + Ok(()) + }; + match source { + KeyInfoSource::KeyValue(KeyValueInfo::Rsa { modulus, exponent }) => { + charge(modulus.len())?; + charge(exponent.len())?; + } + KeyInfoSource::KeyValue(KeyValueInfo::Dsa { p, q, g, y }) => { + for length in [ + 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(), + ] { + charge(length)?; + } + } + KeyInfoSource::KeyValue(KeyValueInfo::Ec { + curve_oid, + public_key, + }) => { + charge(curve_oid.len())?; + charge(public_key.len())?; + } + KeyInfoSource::DerEncodedKeyValue(bytes) => charge(bytes.len())?, + KeyInfoSource::X509Data(data) => { + for certificate in &data.certificates { + charge(certificate.len())?; + } + for crl in &data.crls { + charge(crl.len())?; + } + } + _ => {} + } + } + Ok(total) +} + +/// A named secret imported from an xmlsec key store. +pub struct StoredSymmetricKey { + /// Opaque caller-supplied lookup name. + pub name: String, + /// XML Security symmetric-key family. + pub kind: SymmetricKeyKind, + /// Secret bytes, zeroized when the inventory is dropped. + pub bytes: Zeroizing>, + /// Allowed operations. + pub usages: KeyUsages, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// The secret-key family declared by an xmlsec key store. +pub enum SymmetricKeyKind { + /// HMAC signing and verification key. + Hmac, + /// AES content-encryption key. + Aes, + /// Legacy DES key marker; import rejects it until a DES operation exists. + Des, +} + +#[derive(Default)] +/// A caller-owned inventory of imported XML Security key material. +pub struct KeyInventory { + /// Total XML entries inspected, including unsupported algorithms. + entry_count: usize, + /// Supported symmetric keys. + symmetric_keys: Vec, + /// Supported public keys. + public_keys: Vec, + /// Private PKCS#8 keys imported from caller-owned byte sources. + private_keys: Vec, + /// Untrusted certificates available for key lookup or path construction. + lookup_certificates: Vec>, + /// Explicit caller-trusted certificate anchors. + trusted_certificates: Vec>, + /// Caller-supplied DER certificate revocation lists. + crls: Vec>, + material_bytes: usize, +} + +/// Candidate inspections shared by named signing lookups in one operation. +#[derive(Default)] +pub struct SigningLookupBudget { + inspected: usize, +} + +/// Operations for which a caller may authorize a key. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum KeyUsage { + /// XMLDSig signing. + Sign, + /// XMLDSig verification. + Verify, + /// XMLEnc encryption or key wrapping. + Encrypt, + /// XMLEnc decryption or key unwrapping. + Decrypt, +} + +/// Explicit, immutable allowed-use set for one imported key. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct KeyUsages(u8); + +impl KeyUsages { + /// A key usable only for signing. + pub const SIGN: Self = Self(1); + /// A key usable only for verification. + pub const VERIFY: Self = Self(2); + /// A key usable only for encryption. + pub const ENCRYPT: Self = Self(4); + /// A key usable only for decryption. + pub const DECRYPT: Self = Self(8); + + /// Combine disjoint permissions explicitly. + #[must_use] + pub const fn union(self, other: Self) -> Self { + Self(self.0 | other.0) + } + + /// Test one requested operation. + #[must_use] + pub const fn allows(self, usage: KeyUsage) -> bool { + let bit = match usage { + KeyUsage::Sign => Self::SIGN.0, + KeyUsage::Verify => Self::VERIFY.0, + KeyUsage::Encrypt => Self::ENCRYPT.0, + KeyUsage::Decrypt => Self::DECRYPT.0, + }; + self.0 & bit != 0 + } +} + +/// Private key encoded as provider-neutral PKCS#8 DER. +pub struct StoredPrivateKey { + /// Opaque caller-assigned name. + pub name: String, + /// Private key bytes; zeroized on drop. + pub pkcs8_der: Zeroizing>, + /// Allowed operations. + pub usages: KeyUsages, + /// Associated certificates, leaf first when a matching leaf exists. + pub certificate_chain: Vec>, + has_matching_leaf: bool, +} + +impl StoredPrivateKey { + /// Return the chain only when its first certificate matches this private key. + #[must_use] + pub fn matching_certificate_chain(&self) -> Option<&[Vec]> { + self.has_matching_leaf.then_some(&self.certificate_chain) + } +} + +/// A named public key imported from an xmlsec key store. +pub struct StoredPublicKey { + /// Opaque caller-supplied lookup name. + pub name: String, + /// Parsed XMLDSig key material. + pub key_info: KeyInfo, + /// Allowed operations. + pub usages: KeyUsages, +} + +impl StoredPublicKey { + /// Decode this already-selected RSA recipient without searching the inventory again. + #[cfg(feature = "xmlenc")] + pub fn rsa_encryption_key( + &self, + policy: &crate::policy::EncryptionPolicy, + ) -> Result { + policy.validate()?; + if !self.usages.allows(KeyUsage::Encrypt) { + return Err(KeyStoreError::Selection( + "key is not authorized for encryption", + )); + } + for source in &self.key_info.sources { + return match source { + KeyInfoSource::KeyValue(KeyValueInfo::Rsa { modulus, exponent }) => { + rsa_recipient_from_components(modulus, exponent, policy) + } + KeyInfoSource::DerEncodedKeyValue(der) => { + check_encryption_material_size(der.len(), &policy.resources)?; + let (rest, spki) = x509_parser::x509::SubjectPublicKeyInfo::from_der(der) + .map_err(|_| KeyStoreError::Selection("invalid RSA encryption key"))?; + if !rest.is_empty() { + return Err(KeyStoreError::Selection("invalid RSA encryption key")); + } + let x509_parser::public_key::PublicKey::RSA(raw) = spki + .parsed() + .map_err(|_| KeyStoreError::Selection("invalid RSA encryption key"))? + else { + return Err(KeyStoreError::Selection( + "named key is not an RSA public key", + )); + }; + rsa_recipient_preflight(raw.modulus, raw.exponent, policy)?; + RsaPublicKey::from_public_key_der(der) + .map_err(|_| KeyStoreError::Selection("invalid RSA encryption key")) + } + KeyInfoSource::X509Data(data) if data.parsed_certificates.len() == 1 => { + if let Some(certificate) = data.certificates.first() { + check_encryption_material_size(certificate.len(), &policy.resources)?; + } + match &data.parsed_certificates[0].public_key { + X509PublicKeyInfo::Rsa { modulus, exponent } => { + rsa_recipient_from_components(modulus, exponent, policy) + } + _ => Err(KeyStoreError::Selection("certificate does not contain RSA")), + } + } + _ => continue, + }; + } + Err(KeyStoreError::Selection( + "named key is not an RSA public key", + )) + } +} + +/// Policy-aware verification adapter over a caller-owned inventory. +pub struct InventoryVerificationResolver<'a> { + inventory: &'a KeyInventory, +} + +impl<'a> KeyResolver for InventoryVerificationResolver<'a> { + fn resolve<'k>( + &'k self, + key_info: Option<&KeyInfo>, + algorithm: SignatureAlgorithm, + ) -> Result>, DsigError> { + self.resolve_with_policy_and_provider( + key_info, + algorithm, + &crate::policy::VerificationPolicy::default(), + crate::provider::default_provider(), + ) + } + + fn resolve_with_policy<'k>( + &'k self, + key_info: Option<&KeyInfo>, + algorithm: SignatureAlgorithm, + policy: &crate::policy::VerificationPolicy, + ) -> Result>, DsigError> { + self.resolve_with_policy_and_provider( + key_info, + algorithm, + policy, + crate::provider::default_provider(), + ) + } + + fn resolve_with_policy_and_provider<'k>( + &'k self, + key_info: Option<&KeyInfo>, + algorithm: SignatureAlgorithm, + policy: &crate::policy::VerificationPolicy, + provider: &dyn crate::provider::CryptoProvider, + ) -> Result>, DsigError> { + if let Some(info) = key_info { + crate::xmldsig::keys::validate_key_info_source_permissions(info, policy.key_sources)?; + } + let mut candidate: Option<&StoredPublicKey> = None; + let mut secret_candidate: Option<&StoredSymmetricKey> = None; + let mut inspected_candidates = + InspectedKeyCandidateBudget::new(policy.resources.max_key_candidates); + for name in key_info + .into_iter() + .flat_map(|info| &info.sources) + .filter_map(|source| match source { + KeyInfoSource::KeyName(name) => Some(name.as_str()), + _ => None, + }) + { + if self.inventory.symmetric_keys.is_empty() && self.inventory.public_keys.is_empty() { + inspected_candidates.charge()?; + } + let mut symmetric_match = None; + for entry in &self.inventory.symmetric_keys { + inspected_candidates.charge()?; + if entry.name == name { + symmetric_match = Some(entry); + break; + } + } + if let Some(found) = symmetric_match { + if !found.usages.allows(KeyUsage::Verify) || found.kind != SymmetricKeyKind::Hmac { + return Err(DsigError::InvalidStructure { + reason: "named key is not authorized for verification", + }); + } + if candidate.is_some() + || secret_candidate.is_some_and(|previous| !core::ptr::eq(previous, found)) + { + return Err(DsigError::InvalidStructure { + reason: "more than one named verification key matches", + }); + } + secret_candidate = Some(found); + continue; + } + let mut public_match = None; + for entry in &self.inventory.public_keys { + inspected_candidates.charge()?; + if entry.name == name { + public_match = Some(entry); + break; + } + } + if let Some(found) = public_match { + if !found.usages.allows(KeyUsage::Verify) { + return Err(DsigError::InvalidStructure { + reason: "named key is not authorized for verification", + }); + } + if secret_candidate.is_some() + || candidate.is_some_and(|previous| !core::ptr::eq(previous, found)) + { + return Err(DsigError::InvalidStructure { + reason: "more than one named verification key matches", + }); + } + candidate = Some(found); + } + } + if let Some(candidate) = secret_candidate { + if algorithm.hmac_output_bits().is_none() { + return Err(DsigError::InvalidStructure { + reason: "named HMAC key is incompatible with signature method", + }); + } + for (resource, maximum) in [ + ( + crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + policy.resources.max_external_resource_bytes, + ), + ( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + policy.resources.max_external_resource_total_bytes, + ), + ] { + if candidate.bytes.len() > maximum { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource, + maximum, + actual: candidate.bytes.len(), + } + .into()); + } + } + let key = HmacVerificationKey::new(candidate.bytes.to_vec()).map_err(|_| { + DsigError::InvalidStructure { + reason: "invalid named HMAC key", + } + })?; + return Ok(Some(Box::new(key))); + } + let selected_material_bytes = candidate + .map(|candidate| check_selected_public_material(&candidate.key_info, &policy.resources)) + .transpose()? + .unwrap_or(0); + let selected_info = candidate.map_or(key_info, |entry| Some(&entry.key_info)); + let configured_x509_index = selected_info.and_then(|info| { + info.sources.iter().position(|source| match source { + KeyInfoSource::X509Data(data) if data.certificate_chain.is_empty() => { + crate::xmldsig::parse::x509_data_has_lookup_identifiers(data) + } + KeyInfoSource::X509Data(_) => policy.key_trust.verify_x509_chains, + _ => false, + }) + }); + // Try only sources preceding the first configured-X.509 use without + // inspecting or copying inventory certificates that may never be used. + if let Some(info) = selected_info + && let Some(first_x509) = configured_x509_index + && first_x509 != 0 + { + let prefix_resolver = DefaultKeyResolver::new(KeyResolverConfig::default()); + let result = prefix_resolver.resolve_prefix_with_candidate_budget( + info, + algorithm, + policy, + provider, + &mut inspected_candidates, + if candidate.is_some() { + crate::xmldsig::keys::ResolutionScope::TrustedPrefix(first_x509) + } else { + crate::xmldsig::keys::ResolutionScope::DocumentPrefix(first_x509) + }, + ); + match result { + Ok(Some(key)) => return Ok(Some(key)), + Err(DsigError::Policy(violation)) => return Err(violation.into()), + _ => {} + } + } + let fallback = if configured_x509_index.is_some() { + let certificates = self + .inventory + .lookup_certificates + .iter() + .chain(&self.inventory.trusted_certificates); + let crls = self + .inventory + .crls + .iter() + .filter(|_| policy.key_trust.check_crls && policy.key_trust.verify_x509_chains); + let mut total = selected_material_bytes; + for material in certificates.chain(crls) { + if material.len() > policy.resources.max_external_resource_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum: policy.resources.max_external_resource_bytes, + actual: material.len(), + } + .into()); + } + debug_assert!(total <= policy.resources.max_external_resource_total_bytes); + if material.len() > policy.resources.max_external_resource_total_bytes - total { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + maximum: policy.resources.max_external_resource_total_bytes, + actual: total.saturating_add(material.len()), + } + .into()); + } + total += material.len(); + } + DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: self.inventory.lookup_certificates.clone(), + trusted_certs: self.inventory.trusted_certificates.clone(), + crls: if policy.key_trust.check_crls && policy.key_trust.verify_x509_chains { + self.inventory.crls.clone() + } else { + Vec::new() + }, + ..KeyResolverConfig::default() + }) + } else { + DefaultKeyResolver::new(KeyResolverConfig::default()) + }; + if let Some(candidate) = candidate { + return fallback.resolve_trusted_material_with_candidate_budget( + &candidate.key_info, + algorithm, + policy, + provider, + &mut inspected_candidates, + ); + } + fallback.resolve_with_candidate_budget( + key_info, + algorithm, + policy, + provider, + &mut inspected_candidates, + ) + } + + fn consumes_document_key_info(&self) -> bool { + true + } +} + +enum ParsedMaterial { + Symmetric(SymmetricKeyKind, Zeroizing>), + Public(Option), + Dsa(KeyValueInfo, Option>>), + Unsupported, +} + +type ParsedDsaKey = (KeyValueInfo, Option>>); + +#[derive(Debug, thiserror::Error)] +/// Key-store import errors that never include secret material. +pub enum KeyStoreError { + /// The XML structure or key material was invalid. + #[error("invalid xmlsec keys.xml: {0}")] + Invalid(String), + /// The named key is missing, duplicated, or incompatible with the requested operation. + #[error("key inventory selection failed: {0}")] + Selection(&'static str), + /// The active operation policy rejected the key or its resources. + #[error("key inventory policy violation: {0}")] + Policy(#[from] crate::policy::PolicyViolation), + /// A protected container could not be decoded with the supplied password. + #[error("protected key container could not be decoded")] + ProtectedContainer, +} + +impl KeyInventory { + fn retained_material_bytes(&self) -> Result { + let mut total = 0_usize; + let mut add = |length: usize| -> Result<(), KeyStoreError> { + total = total + .checked_add(length) + .ok_or(KeyStoreError::Selection("key material size overflow"))?; + Ok(()) + }; + for key in &self.symmetric_keys { + add(key.name.len())?; + add(key.bytes.len())?; + } + for key in &self.private_keys { + add(key.name.len())?; + add(key.pkcs8_der.len())?; + for certificate in &key.certificate_chain { + add(certificate.len())?; + } + } + for key in &self.public_keys { + add(key.name.len())?; + for source in &key.key_info.sources { + match source { + KeyInfoSource::KeyName(name) => add(name.len())?, + KeyInfoSource::KeyValue(KeyValueInfo::Dsa { p, q, g, y }) => { + add(p.as_ref().map_or(0, Vec::len))?; + add(q.as_ref().map_or(0, Vec::len))?; + add(g.as_ref().map_or(0, Vec::len))?; + add(y.len())?; + } + KeyInfoSource::KeyValue(KeyValueInfo::Rsa { modulus, exponent }) => { + add(modulus.len())?; + add(exponent.len())?; + } + KeyInfoSource::KeyValue(KeyValueInfo::Ec { + curve_oid, + public_key, + }) => { + add(curve_oid.len())?; + add(public_key.len())?; + } + KeyInfoSource::DerEncodedKeyValue(bytes) => add(bytes.len())?, + _ => {} + } + } + } + for certificate in self + .lookup_certificates + .iter() + .chain(&self.trusted_certificates) + { + add(certificate.len())?; + } + for crl in &self.crls { + add(crl.len())?; + } + Ok(total) + } + + /// Number of imported candidates, including unsupported XML entries. + #[must_use] + pub fn entry_count(&self) -> usize { + self.entry_count + } + + /// Imported symmetric keys, without mutable access to inventory bounds. + #[must_use] + pub fn symmetric_keys(&self) -> &[StoredSymmetricKey] { + &self.symmetric_keys + } + + /// Imported public keys, without mutable access to inventory bounds. + #[must_use] + pub fn public_keys(&self) -> &[StoredPublicKey] { + &self.public_keys + } + + /// Imported private keys, without mutable access to inventory bounds. + #[must_use] + pub fn private_keys(&self) -> &[StoredPrivateKey] { + &self.private_keys + } + + /// Combine two caller-owned imports after checking aggregate bytes, + /// candidates, and cross-store name collisions before mutating either. + pub fn extend( + &mut self, + mut other: Self, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + ensure_resource_policy(resources)?; + let candidates = self + .entry_count + .checked_add(other.entry_count) + .ok_or(KeyStoreError::Selection("key candidate count overflow"))?; + if candidates > resources.max_key_candidates { + return Err(KeyStoreError::Selection("key candidate limit exceeded")); + } + let bytes = self + .material_bytes + .checked_add(other.material_bytes) + .ok_or(KeyStoreError::Selection("key material size overflow"))?; + if bytes > resources.max_external_resource_total_bytes { + return Err(KeyStoreError::Selection( + "key material total exceeds resource limit", + )); + } + let mut names = HashSet::new(); + for name in other + .symmetric_keys + .iter() + .map(|entry| entry.name.as_str()) + .chain(other.public_keys.iter().map(|entry| entry.name.as_str())) + .chain(other.private_keys.iter().map(|entry| entry.name.as_str())) + { + names.insert(name); + } + if names.iter().any(|name| { + self.symmetric_keys.iter().any(|entry| entry.name == *name) + || self.public_keys.iter().any(|entry| entry.name == *name) + || self.private_keys.iter().any(|entry| entry.name == *name) + }) { + return Err(KeyStoreError::Selection("duplicate key name")); + } + self.entry_count = candidates; + self.material_bytes = bytes; + self.symmetric_keys.append(&mut other.symmetric_keys); + self.public_keys.append(&mut other.public_keys); + self.private_keys.append(&mut other.private_keys); + self.lookup_certificates + .append(&mut other.lookup_certificates); + self.trusted_certificates + .append(&mut other.trusted_certificates); + self.crls.append(&mut other.crls); + Ok(()) + } + + /// Select an authorized named RSA recipient from XMLDSig RSAKeyValue or + /// DER SubjectPublicKeyInfo without introducing an implicit key source. + #[cfg(feature = "xmlenc")] + pub fn rsa_encryption_key( + &self, + name: &str, + policy: &crate::policy::EncryptionPolicy, + ) -> Result { + policy.validate()?; + let entry = find_named_entry( + &self.public_keys, + name, + &policy.resources, + &mut 0, + |entry| &entry.name, + )? + .ok_or(KeyStoreError::Selection("named encryption key not found"))?; + entry.rsa_encryption_key(policy) + } + + /// Select a named signer under the operation's immutable policy. + pub fn signing_key( + &self, + name: &str, + algorithm: SignatureAlgorithm, + policy: &crate::policy::SigningPolicy, + ) -> Result, KeyStoreError> { + self.signing_key_with_budget(name, algorithm, policy, &mut SigningLookupBudget::default()) + } + + /// Select a named signer while sharing lookup work across caller retries. + pub fn signing_key_with_budget( + &self, + name: &str, + algorithm: SignatureAlgorithm, + policy: &crate::policy::SigningPolicy, + budget: &mut SigningLookupBudget, + ) -> Result, KeyStoreError> { + policy.validate()?; + if algorithm.hmac_output_bits().is_some() { + let entry = find_named_entry( + &self.symmetric_keys, + name, + &policy.resources, + &mut budget.inspected, + |entry| &entry.name, + )? + .ok_or(KeyStoreError::Selection("named signing key not found"))?; + if entry.kind != SymmetricKeyKind::Hmac || !entry.usages.allows(KeyUsage::Sign) { + return Err(KeyStoreError::Selection( + "key is not authorized for signing", + )); + } + check_operation_material_size(entry.bytes.len(), &policy.resources)?; + let key = HmacSigningKey::new(entry.bytes.to_vec()) + .map_err(|_| KeyStoreError::Selection("invalid HMAC key"))?; + validate_signing_key(&key, algorithm, policy).map_err(signing_policy_error)?; + return Ok(Box::new(key)); + } + let entry = find_named_entry( + &self.private_keys, + name, + &policy.resources, + &mut budget.inspected, + |entry| &entry.name, + )? + .ok_or(KeyStoreError::Selection("named signing key not found"))?; + if !entry.usages.allows(KeyUsage::Sign) { + return Err(KeyStoreError::Selection( + "key is not authorized for signing", + )); + } + let der = entry.pkcs8_der.as_slice(); + check_operation_material_size(der.len(), &policy.resources)?; + if let Ok(info) = PrivateKeyInfoRef::try_from(der) + && info.algorithm.oid == dsa::OID + { + preflight_dsa_pkcs8_components(&info)?; + } + let key: Box = match algorithm { + SignatureAlgorithm::RsaSha1 + | SignatureAlgorithm::RsaSha224 + | SignatureAlgorithm::RsaSha256 + | SignatureAlgorithm::RsaSha384 + | SignatureAlgorithm::RsaSha512 => Box::new( + RsaSigningKey::from_pkcs8_der(der) + .map_err(|_| KeyStoreError::Selection("incompatible RSA signing key"))?, + ), + SignatureAlgorithm::DsaSha1 | SignatureAlgorithm::DsaSha256 => Box::new( + DsaSigningKey::from_pkcs8_der(der) + .map_err(|_| KeyStoreError::Selection("incompatible DSA signing key"))?, + ), + SignatureAlgorithm::EcdsaSha1 + | SignatureAlgorithm::EcdsaSha224 + | SignatureAlgorithm::EcdsaSha256 + | SignatureAlgorithm::EcdsaSha384 + | SignatureAlgorithm::EcdsaSha512 => { + if let Ok(key) = EcdsaP256SigningKey::from_pkcs8_der(der) { + Box::new(key) + } else if let Ok(key) = EcdsaP384SigningKey::from_pkcs8_der(der) { + Box::new(key) + } else { + Box::new( + EcdsaP521SigningKey::from_pkcs8_der(der) + .map_err(|_| KeyStoreError::Selection("incompatible EC signing key"))?, + ) + } + } + _ => return Err(KeyStoreError::Selection("unsupported signature method")), + }; + validate_signing_key(key.as_ref(), algorithm, policy).map_err(signing_policy_error)?; + Ok(key) + } + + /// Select a named direct AES key or RSA private-key transport resolver. + #[cfg(feature = "xmlenc")] + pub fn decryption_resolver( + &self, + name: &str, + policy: &crate::policy::DecryptionPolicy, + ) -> Result, KeyStoreError> { + policy.validate()?; + let mut visited = 0; + if let Some(entry) = find_named_entry( + &self.symmetric_keys, + name, + &policy.resources, + &mut visited, + |entry| &entry.name, + )? { + if entry.kind != SymmetricKeyKind::Aes || !entry.usages.allows(KeyUsage::Decrypt) { + return Err(KeyStoreError::Selection( + "key is not authorized for decryption", + )); + } + check_selected_material_size(entry.bytes.len(), &policy.resources)?; + return Ok(Box::new(crate::xmlenc::SymmetricKeyDecryptor::new( + entry.bytes.to_vec(), + ))); + } + let entry = find_named_entry( + &self.private_keys, + name, + &policy.resources, + &mut visited, + |entry| &entry.name, + )? + .ok_or(KeyStoreError::Selection("named decryption key not found"))?; + if !entry.usages.allows(KeyUsage::Decrypt) { + return Err(KeyStoreError::Selection( + "key is not authorized for decryption", + )); + } + check_selected_material_size(entry.pkcs8_der.len(), &policy.resources)?; + let key = RsaPrivateKey::from_pkcs8_der(&entry.pkcs8_der) + .map_err(|_| KeyStoreError::Selection("incompatible RSA decryption key"))?; + Ok(Box::new(crate::xmlenc::PrivateKeyDecryptor::new(key))) + } + /// Build a resolver from this inventory and one immutable key-store snapshot. + /// Trust anchors are copied once, not on each candidate lookup. + #[must_use] + pub fn verification_resolver(&self) -> InventoryVerificationResolver<'_> { + InventoryVerificationResolver { inventory: self } + } + /// Register raw symmetric bytes under a unique name. + pub fn add_symmetric( + &mut self, + name: String, + kind: SymmetricKeyKind, + bytes: Vec, + usages: KeyUsages, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.check_new_name(&name, resources)?; + if bytes.is_empty() + || bytes.len() > resources.max_external_resource_bytes + || (kind == SymmetricKeyKind::Aes && !matches!(bytes.len(), 16 | 24 | 32)) + { + return Err(KeyStoreError::Selection("invalid symmetric key length")); + } + let permitted = match kind { + SymmetricKeyKind::Hmac => KeyUsages::SIGN.union(KeyUsages::VERIFY), + SymmetricKeyKind::Aes => KeyUsages::ENCRYPT.union(KeyUsages::DECRYPT), + SymmetricKeyKind::Des => { + return Err(KeyStoreError::Selection("DES encryption is unsupported")); + } + }; + if usages.0 == 0 || usages.0 & !permitted.0 != 0 { + return Err(KeyStoreError::Selection( + "symmetric key usage is incompatible", + )); + } + self.reserve_material(named_material_length(&name, bytes.len(), 1)?, resources)?; + self.symmetric_keys.push(StoredSymmetricKey { + name, + kind, + bytes: Zeroizing::new(bytes), + usages, + }); + self.entry_count += 1; + Ok(()) + } + + /// Register DER SubjectPublicKeyInfo or a complete X.509 certificate. + /// A certificate is a lookup candidate, never an implicit trust anchor. + pub fn add_public_der( + &mut self, + name: String, + der: Vec, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.add_public_der_inner(name, der, None, resources) + } + + /// Register public DER with explicit verification/encryption permissions. + pub fn add_public_der_with_usages( + &mut self, + name: String, + der: Vec, + usages: KeyUsages, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.add_public_der_inner(name, der, Some(usages), resources) + } + + fn add_public_der_inner( + &mut self, + name: String, + der: Vec, + usages: Option, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.check_new_name(&name, resources)?; + let permitted = KeyUsages::VERIFY.union(KeyUsages::ENCRYPT); + if usages.is_some_and(|usages| usages.0 == 0 || usages.0 & !permitted.0 != 0) { + return Err(KeyStoreError::Selection("public key usage is incompatible")); + } + if der.len() > resources.max_external_resource_bytes { + return Err(KeyStoreError::Selection( + "public key exceeds resource limit", + )); + } + self.check_material_capacity(named_material_length(&name, der.len(), 2)?, resources)?; + let material_len = der.len(); + let mut key_info = KeyInfo::default(); + key_info.sources.push(KeyInfoSource::KeyName(name.clone())); + let is_rsa = if let Ok((rest, spki)) = + x509_parser::x509::SubjectPublicKeyInfo::from_der(&der) + && rest.is_empty() + && spki.raw == der + { + let is_rsa = crate::xmldsig::keys::supported_parsed_spki_is_rsa(&spki, &der) + .map_err(|_| KeyStoreError::Selection("unsupported public key algorithm"))?; + key_info + .sources + .push(KeyInfoSource::DerEncodedKeyValue(der)); + is_rsa + } else { + let (rest, certificate) = X509Certificate::from_der(&der) + .map_err(|_| KeyStoreError::Selection("invalid public key or X.509 certificate"))?; + if !rest.is_empty() { + return Err(KeyStoreError::Selection("invalid X.509 certificate")); + } + let parsed = crate::xmldsig::parse::parse_x509_certificate(&der) + .map_err(|_| KeyStoreError::Selection("unsupported X.509 certificate key"))?; + let is_rsa = crate::xmldsig::keys::supported_parsed_spki_is_rsa( + certificate.public_key(), + certificate.public_key().raw, + ) + .map_err(|_| KeyStoreError::Selection("unsupported X.509 certificate key"))?; + key_info.sources.push(KeyInfoSource::X509Data(X509DataInfo { + certificates: vec![der], + parsed_certificates: vec![parsed], + certificate_chain: vec![0], + ..X509DataInfo::default() + })); + is_rsa + }; + let usages = usages.unwrap_or(if is_rsa { permitted } else { KeyUsages::VERIFY }); + if usages.allows(KeyUsage::Encrypt) && !is_rsa { + return Err(KeyStoreError::Selection( + "only RSA public keys can be used for encryption", + )); + } + self.reserve_material(named_material_length(&name, material_len, 2)?, resources)?; + self.public_keys.push(StoredPublicKey { + name, + key_info, + usages, + }); + self.entry_count += 1; + Ok(()) + } + + /// Import one PEM-encoded public key. RFC 7468 labels select SPKI or + /// PKCS#1; extra text and multiple armor blocks are rejected. + pub fn add_public_pem( + &mut self, + name: String, + bytes: &[u8], + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.add_public_pem_inner(name, bytes, None, resources) + } + + /// Import one PEM public key with explicit verification/encryption permissions. + pub fn add_public_pem_with_usages( + &mut self, + name: String, + bytes: &[u8], + usages: KeyUsages, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.add_public_pem_inner(name, bytes, Some(usages), resources) + } + + fn add_public_pem_inner( + &mut self, + name: String, + bytes: &[u8], + usages: Option, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + ensure_resource_policy(resources)?; + self.check_new_name(&name, resources)?; + self.check_material_capacity(named_material_length(&name, bytes.len(), 2)?, resources)?; + let block = single_pem_block(bytes, resources.max_external_resource_bytes)?; + let der = match block.tag() { + "PUBLIC KEY" => block.into_contents(), + "RSA PUBLIC KEY" => { + let components = rsa::pkcs1::RsaPublicKey::from_der(block.contents()) + .map_err(|_| KeyStoreError::Selection("invalid RSA public key"))?; + crate::xmldsig::keys::bounded_rsa_public_components( + components.modulus.as_bytes(), + components.public_exponent.as_bytes(), + ) + .map_err(|_| KeyStoreError::Selection("RSA public key exceeds safety limit"))?; + RsaPublicKey::from_pkcs1_der(block.contents()) + .ok() + .and_then(|key| key.to_public_key_der().ok()) + .map(|der| der.as_bytes().to_vec()) + .ok_or(KeyStoreError::Selection("invalid RSA public key"))? + } + _ => return Err(KeyStoreError::Selection("unsupported public PEM label")), + }; + let previous_total = self.material_bytes; + let charged_total = self.check_material_capacity( + named_material_length(&name, bytes.len().max(der.len()), 2)?, + resources, + )?; + self.add_public_der_inner(name, der, usages, resources)?; + debug_assert!(self.material_bytes >= previous_total); + self.material_bytes = charged_total; + Ok(()) + } + + /// Import a DER private key as PKCS#8 (plain or encrypted) or RSA PKCS#1. + /// Passwords are consulted only for a structurally encrypted container; + /// a wrong password never retries a plaintext decoder. + pub fn add_private_der( + &mut self, + name: String, + bytes: &[u8], + password: Option<&[u8]>, + usages: KeyUsages, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.check_new_name(&name, resources)?; + if bytes.len() > resources.max_external_resource_bytes { + return Err(KeyStoreError::Selection( + "private key exceeds resource limit", + )); + } + self.check_material_capacity(named_material_length(&name, bytes.len(), 1)?, resources)?; + let permitted = KeyUsages::SIGN.union(KeyUsages::DECRYPT); + if usages.0 == 0 || usages.0 & !permitted.0 != 0 { + return Err(KeyStoreError::Selection( + "private key usage is incompatible", + )); + } + let der = if PrivateKeyInfoRef::try_from(bytes).is_ok() { + Zeroizing::new(bytes.to_vec()) + } else if let Ok(encrypted) = EncryptedPrivateKeyInfoRef::try_from(bytes) { + enforce_pkcs8_kdf_policy(&encrypted, resources)?; + let password = password.ok_or(KeyStoreError::ProtectedContainer)?; + let plain = encrypted + .decrypt(password) + .map_err(|_| KeyStoreError::ProtectedContainer)?; + Zeroizing::new(plain.as_bytes().to_vec()) + } else { + preflight_rsa_pkcs1_components(bytes)?; + let rsa = RsaPrivateKey::from_pkcs1_der(bytes) + .map_err(|_| KeyStoreError::Selection("unsupported private key DER"))?; + let normalized = rsa + .to_pkcs8_der() + .map_err(|_| KeyStoreError::Selection("invalid RSA private key"))?; + Zeroizing::new(normalized.as_bytes().to_vec()) + }; + if der.len() > resources.max_external_resource_bytes { + return Err(KeyStoreError::Selection( + "private key exceeds resource limit", + )); + } + private_key_spki(&der)?; + if usages.allows(KeyUsage::Decrypt) && RsaPrivateKey::from_pkcs8_der(&der).is_err() { + return Err(KeyStoreError::Selection( + "only RSA private keys can be used for decryption", + )); + } + self.reserve_material( + named_material_length(&name, bytes.len().max(der.len()), 1)?, + resources, + )?; + self.private_keys.push(StoredPrivateKey { + name, + pkcs8_der: der, + usages, + certificate_chain: Vec::new(), + has_matching_leaf: false, + }); + self.entry_count += 1; + Ok(()) + } + + /// Import private DER using a caller-owned password callback only when + /// the input is an encrypted PKCS#8 container. + pub fn add_private_der_with_password_callback( + &mut self, + name: String, + bytes: &[u8], + password: F, + usages: KeyUsages, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> + where + F: FnOnce() -> Option>>, + { + self.check_new_name(&name, resources)?; + if bytes.len() > resources.max_external_resource_bytes { + return Err(KeyStoreError::Selection( + "private key exceeds resource limit", + )); + } + self.check_material_capacity(named_material_length(&name, bytes.len(), 1)?, resources)?; + let secret = if let Ok(encrypted) = EncryptedPrivateKeyInfoRef::try_from(bytes) { + enforce_pkcs8_kdf_policy(&encrypted, resources)?; + Some(password().ok_or(KeyStoreError::ProtectedContainer)?) + } else { + None + }; + self.add_private_der( + name, + bytes, + secret.as_deref().map(Vec::as_slice), + usages, + resources, + ) + } + + /// Import one PEM private key, including encrypted PKCS#8. Traditional + /// OpenSSL PEM encryption is handled at the CLI compatibility boundary. + pub fn add_private_pem( + &mut self, + name: String, + bytes: &[u8], + password: Option<&[u8]>, + usages: KeyUsages, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + ensure_resource_policy(resources)?; + self.check_new_name(&name, resources)?; + self.check_material_capacity(named_material_length(&name, bytes.len(), 1)?, resources)?; + let block = single_pem_block(bytes, resources.max_external_resource_bytes)?; + match block.tag() { + "PRIVATE KEY" | "ENCRYPTED PRIVATE KEY" | "RSA PRIVATE KEY" => { + let der = Zeroizing::new(block.into_contents()); + let previous_total = self.material_bytes; + let name_len = name.len(); + self.add_private_der(name, &der, password, usages, resources)?; + let retained_len = self.material_bytes - previous_total; + self.material_bytes = + previous_total + name_len + bytes.len().max(retained_len - name_len); + Ok(()) + } + _ => Err(KeyStoreError::Selection("unsupported private PEM label")), + } + } + + /// Import a bounded PKCS#12 bundle from caller-owned bytes. The key may + /// sign; RSA keys may also decrypt. A bundle with more than one private + /// key is rejected rather than assigning names from iteration order. + pub fn add_pkcs12( + &mut self, + name: String, + bytes: &[u8], + password: &str, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.add_pkcs12_inner( + name, + bytes, + password, + KeyUsages::SIGN.union(KeyUsages::DECRYPT), + resources, + true, + ) + } + + /// Import PKCS#12 using a caller-owned password callback. Its result is + /// zeroized after decoding and callback failure exposes no diagnostic. + pub fn add_pkcs12_with_password_callback( + &mut self, + name: String, + bytes: &[u8], + password: F, + usages: KeyUsages, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> + where + F: FnOnce() -> Option>, + { + let limits = self.pkcs12_import_limits(&name, bytes, usages, resources)?; + preflight_pkcs12_outer_mac_kdf(bytes, &limits)?; + let secret = password().ok_or(KeyStoreError::ProtectedContainer)?; + self.add_pkcs12_with_usages(name, bytes, &secret, usages, resources) + } + + /// 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, + mut usages: KeyUsages, + resources: &ResourcePolicy, + auto_decrypt: bool, + ) -> Result<(), KeyStoreError> { + let limits = self.pkcs12_import_limits(&name, bytes, usages, resources)?; + let mut contents = parse_pkcs12_with_limits(bytes, password, &limits) + .map_err(|error| classify_pkcs12_error(error, &limits))?; + if contents.private_keys.len() != 1 { + return Err(KeyStoreError::Selection( + "PKCS#12 bundle must contain exactly one private key", + )); + } + let private_key = contents + .private_keys + .pop() + .ok_or(KeyStoreError::ProtectedContainer)?; + let mut certificates = contents.certificates; + let spki = private_key_spki(private_key.as_ref())?; + if usages.allows(KeyUsage::Decrypt) + && RsaPrivateKey::from_pkcs8_der(private_key.as_ref()).is_err() + { + if auto_decrypt { + usages = KeyUsages::SIGN; + } else { + return Err(KeyStoreError::Selection( + "only RSA private keys can be used for decryption", + )); + } + } + let mut matching_leaf = None; + for certificate in &certificates { + let (rest, parsed) = X509Certificate::from_der(certificate) + .map_err(|_| KeyStoreError::Selection("invalid certificate in PKCS#12"))?; + if !rest.is_empty() { + return Err(KeyStoreError::Selection("invalid certificate in PKCS#12")); + } + if parsed.public_key().raw == spki.as_slice() { + if matching_leaf.is_some_and(|leaf: &[u8]| leaf != certificate.as_slice()) { + return Err(KeyStoreError::Selection( + "ambiguous certificate for PKCS#12 private key", + )); + } + // RFC 7292 section 4.2 permits repeated certificate bags. + // Identical DER is the same leaf, not a second candidate. + matching_leaf = Some(certificate.as_slice()); + } + } + // PKCS#12 may carry unrelated CA certificates. They remain lookup + // material; only an actual SPKI match is promoted to the leaf slot. + let has_matching_leaf = matching_leaf.is_some(); + if let Some(leaf) = matching_leaf { + let position = certificates + .iter() + .position(|candidate| candidate == leaf) + .ok_or(KeyStoreError::ProtectedContainer)?; + certificates.swap(0, position); + let mut index = 1; + while index < certificates.len() { + if certificates[index] == certificates[0] { + certificates.remove(index); + } else { + index += 1; + } + } + } + let decoded_bytes = certificates + .iter() + .try_fold(private_key.len(), |total, cert| { + total + .checked_add(cert.len()) + .ok_or(KeyStoreError::Selection("key material size overflow")) + })?; + let retained_candidates = 1_usize + .checked_add(certificates.len()) + .ok_or(KeyStoreError::Selection("key candidate count overflow"))?; + let remaining_candidates = resources.max_key_candidates - self.entry_count; + if retained_candidates > remaining_candidates { + return Err(KeyStoreError::Selection("key candidate limit exceeded")); + } + self.reserve_material( + named_material_length(&name, decoded_bytes.max(bytes.len()), 1)?, + resources, + )?; + self.private_keys.push(StoredPrivateKey { + name, + pkcs8_der: Zeroizing::new(private_key.as_ref().to_vec()), + usages, + certificate_chain: certificates, + has_matching_leaf, + }); + self.entry_count += retained_candidates; + Ok(()) + } + + fn pkcs12_import_limits( + &self, + name: &str, + bytes: &[u8], + usages: KeyUsages, + resources: &ResourcePolicy, + ) -> Result { + self.check_new_name(name, resources)?; + let permitted = KeyUsages::SIGN.union(KeyUsages::DECRYPT); + if usages.0 == 0 || usages.0 & !permitted.0 != 0 { + return Err(KeyStoreError::Selection( + "private key usage is incompatible", + )); + } + if bytes.len() > resources.max_external_resource_bytes { + return Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_bytes, + }, + )); + } + self.check_material_capacity(named_material_length(name, bytes.len(), 1)?, resources)?; + let remaining_candidates = resources.max_key_candidates - self.entry_count; + let mut limits = Pkcs12Limits::default(); + limits.max_kdf_work = limits + .max_kdf_work + .min(resources.max_key_import_kdf_work as u64); + limits.max_iterations = limits + .max_iterations + .min(u32::try_from(resources.max_key_import_kdf_work).unwrap_or(u32::MAX)); + limits.max_input_len = limits + .max_input_len + .min(resources.max_external_resource_bytes); + limits.max_bags = limits.max_bags.min(remaining_candidates); + limits.max_content_infos = limits.max_content_infos.min(remaining_candidates); + if bytes.len() > limits.max_input_len { + return Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum: limits.max_input_len, + }, + )); + } + Ok(limits) + } + + fn check_new_name(&self, name: &str, resources: &ResourcePolicy) -> Result<(), KeyStoreError> { + ensure_resource_policy(resources)?; + if name.is_empty() || self.entry_count >= resources.max_key_candidates { + return Err(KeyStoreError::Selection( + "name or key candidate limit is invalid", + )); + } + if name.len() > resources.max_external_resource_bytes { + return Err(KeyStoreError::Selection("key name exceeds resource limit")); + } + self.check_material_capacity(name.len(), resources)?; + if self.symmetric_keys.iter().any(|key| key.name == name) + || self.public_keys.iter().any(|key| key.name == name) + || self.private_keys.iter().any(|key| key.name == name) + { + return Err(KeyStoreError::Selection("duplicate key name")); + } + Ok(()) + } + + fn check_material_capacity( + &self, + length: usize, + resources: &ResourcePolicy, + ) -> Result { + let total = self + .material_bytes + .checked_add(length) + .ok_or(KeyStoreError::Selection("key material size overflow"))?; + if total > resources.max_external_resource_total_bytes { + return Err(KeyStoreError::Selection( + "key material total exceeds resource limit", + )); + } + Ok(total) + } + + fn reserve_material( + &mut self, + length: usize, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + self.material_bytes = self.check_material_capacity(length, resources)?; + Ok(()) + } + + /// Import a DER certificate as an untrusted lookup candidate or an + /// explicitly caller-trusted anchor. Parsing alone never grants trust. + pub fn add_certificate_der( + &mut self, + der: Vec, + trusted_anchor: bool, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + ensure_resource_policy(resources)?; + if der.len() > resources.max_external_resource_bytes { + return Err(KeyStoreError::Selection( + "certificate exceeds resource limit", + )); + } + let (rest, _) = X509Certificate::from_der(&der) + .map_err(|_| KeyStoreError::Selection("invalid X.509 certificate"))?; + if !rest.is_empty() { + return Err(KeyStoreError::Selection("invalid X.509 certificate")); + } + if self.entry_count >= resources.max_key_candidates { + return Err(KeyStoreError::Selection("key candidate limit exceeded")); + } + self.reserve_material(der.len(), resources)?; + if trusted_anchor { + self.trusted_certificates.push(der); + } else { + self.lookup_certificates.push(der); + } + self.entry_count += 1; + Ok(()) + } + + /// Import a DER CRL for policy-controlled revocation checks. + pub fn add_crl_der( + &mut self, + der: Vec, + resources: &ResourcePolicy, + ) -> Result<(), KeyStoreError> { + ensure_resource_policy(resources)?; + if der.len() > resources.max_external_resource_bytes { + return Err(KeyStoreError::Selection("CRL exceeds resource limit")); + } + let (rest, _) = x509_parser::revocation_list::CertificateRevocationList::from_der(&der) + .map_err(|_| KeyStoreError::Selection("invalid X.509 CRL"))?; + if !rest.is_empty() { + return Err(KeyStoreError::Selection("invalid X.509 CRL")); + } + if self.entry_count >= resources.max_key_candidates { + return Err(KeyStoreError::Selection("key candidate limit exceeded")); + } + self.reserve_material(der.len(), resources)?; + self.crls.push(der); + self.entry_count += 1; + Ok(()) + } + /// Import caller-owned XML bytes under the operation's XML and resource snapshot. + pub fn from_xml_bytes( + bytes: &[u8], + policy: &P, + backend: XmlBackend, + ) -> Result { + let resources = policy.resource_policy(); + ensure_resource_policy(resources)?; + if bytes.len() > resources.max_external_resource_bytes + || bytes.len() > resources.max_external_resource_total_bytes + { + return Err(KeyStoreError::Selection( + "XML key store exceeds resource limit", + )); + } + let settings = DocumentParseSettings::from_policy(policy.xml_input_policy(), resources) + .with_backend(backend); + let budget = XmlParseWorkBudget::from_resources(resources); + let text = crate::document::decode_xml_with_budget( + bytes, + resources + .max_xml_document_bytes + .min(resources.max_external_resource_bytes), + Some(&budget), + ) + .map_err(|error| KeyStoreError::Invalid(error.to_string()))?; + let document = parse_borrowed_with_settings_and_budget(&text, settings, Some(&budget)) + .map_err(|error| KeyStoreError::Invalid(error.to_string()))?; + let root = document.root_element(); + if !root.has_tag_name((XMLSEC_NS, "Keys")) { + return Err(KeyStoreError::Invalid("expected xmlsec Keys root".into())); + } + let mut names = HashSet::new(); + let mut store = Self::default(); + let mut entry_count = 0_usize; + for info in root.children().filter(|child| child.is_element()) { + if !info.has_tag_name((XMLDSIG_NS, "KeyInfo")) { + return Err(KeyStoreError::Invalid("unexpected child of Keys".into())); + } + if entry_count >= resources.max_key_candidates { + return Err(KeyStoreError::Invalid( + "key candidate limit exceeded".into(), + )); + } + entry_count += 1; + let mut name = None; + let mut value = None; + for child in info.children().filter(|child| child.is_element()) { + if child.has_tag_name((XMLDSIG_NS, "KeyName")) { + if name.is_some() { + return Err(KeyStoreError::Invalid("duplicate KeyName".into())); + } + let text = element_text(child)?; + if text.is_empty() { + return Err(KeyStoreError::Invalid("empty KeyName".into())); + } + name = Some(text); + } else if child.has_tag_name((XMLDSIG_NS, "KeyValue")) { + if value.is_some() { + return Err(KeyStoreError::Invalid("duplicate KeyValue".into())); + } + let mut values = child.children().filter(|node| node.is_element()); + let key = values.next().ok_or_else(|| { + KeyStoreError::Invalid("KeyValue has no key material".into()) + })?; + if values.next().is_some() { + return Err(KeyStoreError::Invalid("ambiguous KeyValue".into())); + } + let material = if key.has_tag_name((XMLSEC_NS, "HMACKeyValue")) { + Some(SymmetricKeyKind::Hmac) + } else if key.has_tag_name((XMLSEC_NS, "AESKeyValue")) { + Some(SymmetricKeyKind::Aes) + } else if key.has_tag_name((XMLSEC_NS, "DESKeyValue")) { + Some(SymmetricKeyKind::Des) + } else { + None + }; + value = Some(if let Some(kind) = material { + ParsedMaterial::Symmetric(kind, Zeroizing::new(decode_xml_base64(key)?)) + } else if key.has_tag_name((XMLDSIG_NS, "DSAKeyValue")) { + let (public, private) = parse_xmlsec_dsa_key_value(key)?; + ParsedMaterial::Dsa(public, private) + } else if key.has_tag_name((XMLDSIG_NS, "RSAKeyValue")) + // XMLDSig 1.1 section 4.5.2.3 places ECKeyValue in dsig11: + // https://www.w3.org/TR/2013/REC-xmldsig-core1-20130411/#sec-ECKeyValue + || key.has_tag_name((XMLDSIG11_NS, "ECKeyValue")) + { + ParsedMaterial::Public(None) + } else { + ParsedMaterial::Unsupported + }); + } else { + return Err(KeyStoreError::Invalid("unsupported KeyInfo child".into())); + } + } + let name = name.ok_or_else(|| KeyStoreError::Invalid("missing KeyName".into()))?; + let material = + value.ok_or_else(|| KeyStoreError::Invalid("missing KeyValue".into()))?; + if !names.insert(name.clone()) { + return Err(KeyStoreError::Invalid("duplicate key name".into())); + } + match material { + ParsedMaterial::Symmetric(kind, bytes) => { + if bytes.is_empty() { + return Err(KeyStoreError::Invalid("empty symmetric key".into())); + } + if kind == SymmetricKeyKind::Aes && !matches!(bytes.len(), 16 | 24 | 32) { + return Err(KeyStoreError::Invalid("invalid AES key length".into())); + } + let usages = match kind { + SymmetricKeyKind::Hmac => KeyUsages::SIGN.union(KeyUsages::VERIFY), + SymmetricKeyKind::Aes => KeyUsages::ENCRYPT.union(KeyUsages::DECRYPT), + SymmetricKeyKind::Des => { + return Err(KeyStoreError::Selection("DES encryption is unsupported")); + } + }; + store.symmetric_keys.push(StoredSymmetricKey { + name, + kind, + bytes, + usages, + }); + } + ParsedMaterial::Public(manual_value) => { + let key_info = if let Some(value) = manual_value { + let mut key_info = KeyInfo::default(); + key_info.sources.push(KeyInfoSource::KeyName(name.clone())); + key_info.sources.push(KeyInfoSource::KeyValue(value)); + key_info + } else { + parse_key_info(info) + .map_err(|error| KeyStoreError::Invalid(error.to_string()))? + }; + let is_rsa = key_info + .sources + .iter() + .find_map(|source| match source { + KeyInfoSource::KeyValue(value) => Some(value), + _ => None, + }) + .ok_or_else(|| KeyStoreError::Invalid("invalid public KeyValue".into()))?; + let is_rsa = crate::xmldsig::keys::supported_key_value_is_rsa(is_rsa) + .map_err(|_| KeyStoreError::Invalid("invalid public KeyValue".into()))?; + let usages = if is_rsa { + KeyUsages::VERIFY.union(KeyUsages::ENCRYPT) + } else { + KeyUsages::VERIFY + }; + store.public_keys.push(StoredPublicKey { + name, + key_info, + usages, + }); + } + ParsedMaterial::Dsa(public, private) => { + let mut key_info = KeyInfo::default(); + key_info.sources.push(KeyInfoSource::KeyName(name.clone())); + key_info.sources.push(KeyInfoSource::KeyValue(public)); + if let Some(pkcs8_der) = private { + store.private_keys.push(StoredPrivateKey { + name: name.clone(), + pkcs8_der, + usages: KeyUsages::SIGN, + certificate_chain: Vec::new(), + has_matching_leaf: false, + }); + } + store.public_keys.push(StoredPublicKey { + name, + key_info, + usages: KeyUsages::VERIFY, + }); + } + ParsedMaterial::Unsupported => {} + } + } + store.entry_count = entry_count; + // Decoding can retain both public components and a derived private key. + // Keep the input charge too, so compact XML never lowers the import budget. + store.material_bytes = bytes.len().max(store.retained_material_bytes()?); + if store.material_bytes > resources.max_external_resource_total_bytes { + return Err(KeyStoreError::Selection( + "key material total exceeds resource limit", + )); + } + Ok(store) + } +} + +fn check_operation_material_size( + length: usize, + resources: &ResourcePolicy, +) -> Result<(), KeyStoreError> { + if length > resources.max_external_resource_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_bytes, + actual: length, + } + .into()); + } + if length > resources.max_external_resource_total_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_total_bytes, + actual: length, + } + .into()); + } + Ok(()) +} + +#[cfg(feature = "xmlenc")] +fn check_selected_material_size( + length: usize, + resources: &ResourcePolicy, +) -> Result<(), KeyStoreError> { + check_operation_material_size(length, resources) +} + +#[cfg(feature = "xmlenc")] +fn check_encryption_material_size( + length: usize, + resources: &ResourcePolicy, +) -> Result<(), KeyStoreError> { + check_operation_material_size(length, resources) +} + +#[cfg(feature = "xmlenc")] +fn rsa_recipient_preflight( + modulus: &[u8], + exponent: &[u8], + policy: &crate::policy::EncryptionPolicy, +) -> Result<(), KeyStoreError> { + let combined = modulus + .len() + .checked_add(exponent.len()) + .ok_or(KeyStoreError::Selection("encryption key size overflow"))?; + check_encryption_material_size(combined, &policy.resources)?; + policy + .rsa_keys + .validate_components("encryption", modulus, exponent)?; + Ok(()) +} + +#[cfg(feature = "xmlenc")] +fn rsa_recipient_from_components( + modulus: &[u8], + exponent: &[u8], + policy: &crate::policy::EncryptionPolicy, +) -> Result { + rsa_recipient_preflight(modulus, exponent, policy)?; + let first_nonzero = modulus + .iter() + .position(|byte| *byte != 0) + .ok_or(KeyStoreError::Selection("invalid RSA encryption key"))?; + RsaPublicKey::new( + BoxedUint::from_be_slice_vartime(&modulus[first_nonzero..]), + BoxedUint::from_be_slice_vartime(exponent), + ) + .map_err(|_| KeyStoreError::Selection("invalid RSA encryption key")) +} + +fn signing_policy_error(error: crate::xmldsig::SigningError) -> KeyStoreError { + match error { + crate::xmldsig::SigningError::Policy(violation) => violation.into(), + _ => KeyStoreError::Selection("signing key violates operation policy"), + } +} + +fn ensure_resource_policy(resources: &ResourcePolicy) -> Result<(), KeyStoreError> { + resources.validate().map_err(Into::into) +} + +fn find_named_entry<'a, T>( + entries: &'a [T], + name: &str, + resources: &ResourcePolicy, + visited: &mut usize, + entry_name: impl Fn(&T) -> &str, +) -> Result, KeyStoreError> { + for entry in entries { + let next = visited + .checked_add(1) + .ok_or(KeyStoreError::Selection("key candidate count overflow"))?; + resources.validate_key_candidates(next)?; + *visited = next; + if entry_name(entry) == name { + return Ok(Some(entry)); + } + } + Ok(None) +} + +fn named_material_length( + name: &str, + payload: usize, + retained_names: usize, +) -> Result { + name.len() + .checked_mul(retained_names) + .and_then(|names| names.checked_add(payload)) + .ok_or(KeyStoreError::Selection("key material size overflow")) +} + +fn private_key_spki(der: &[u8]) -> Result, KeyStoreError> { + let info = PrivateKeyInfoRef::try_from(der) + .map_err(|_| KeyStoreError::Selection("unsupported PKCS#12 private key"))?; + if info.algorithm.oid == rsa::pkcs1::ALGORITHM_OID { + preflight_rsa_pkcs1_components(info.private_key.as_bytes())?; + } else if info.algorithm.oid == dsa::OID { + preflight_dsa_pkcs8_components(&info)?; + } + macro_rules! try_key { + ($key:ty) => { + if let Ok(key) = <$key>::from_pkcs8_der(der) { + return key + .public_key_info() + .ok() + .and_then(|info| info.spki_der().map(ToOwned::to_owned)) + .ok_or(KeyStoreError::Selection("private key has no public key")); + } + }; + } + try_key!(RsaSigningKey); + try_key!(DsaSigningKey); + try_key!(EcdsaP256SigningKey); + try_key!(EcdsaP384SigningKey); + try_key!(EcdsaP521SigningKey); + Err(KeyStoreError::Selection("unsupported PKCS#12 private key")) +} + +fn preflight_rsa_pkcs1_components(der: &[u8]) -> Result<(), KeyStoreError> { + let key = rsa::pkcs1::RsaPrivateKey::from_der(der) + .map_err(|_| KeyStoreError::Selection("invalid RSA private key"))?; + let modulus = key.modulus.as_bytes(); + let maximum = crate::hard_limits::RSA_MODULUS_BIT_CEILING; + if modulus.len() > maximum.div_ceil(8) + || modulus + .first() + .is_some_and(|first| modulus.len() * 8 - first.leading_zeros() as usize > maximum) + { + return Err(KeyStoreError::Selection("RSA modulus exceeds safety limit")); + } + if [ + key.public_exponent, + key.private_exponent, + key.prime1, + key.prime2, + key.exponent1, + key.exponent2, + key.coefficient, + ] + .into_iter() + .any(|component| component.as_bytes().len() > maximum.div_ceil(8)) + || key.other_prime_infos.as_ref().is_some_and(|infos| { + infos.iter().any(|info| { + [info.prime, info.exponent, info.coefficient] + .into_iter() + .any(|component| component.as_bytes().len() > maximum.div_ceil(8)) + }) + }) + { + return Err(KeyStoreError::Selection( + "RSA component exceeds safety limit", + )); + } + Ok(()) +} + +#[derive(der::Sequence)] +struct BorrowedDsaParameters<'a> { + p: der::asn1::UintRef<'a>, + q: der::asn1::UintRef<'a>, + g: der::asn1::UintRef<'a>, +} + +fn preflight_dsa_pkcs8_components(info: &PrivateKeyInfoRef<'_>) -> Result<(), KeyStoreError> { + let parameters = info + .algorithm + .parameters + .as_ref() + .ok_or(KeyStoreError::Selection("missing DSA parameters"))? + .decode_as::>() + .map_err(|_| KeyStoreError::Selection("invalid DSA parameters"))?; + let x = der::asn1::UintRef::from_der(info.private_key.as_bytes()) + .map_err(|_| KeyStoreError::Selection("invalid DSA private exponent"))?; + if [parameters.p, parameters.q, parameters.g, x] + .into_iter() + .any(|component| { + component.as_bytes().len() > crate::hard_limits::DSA_KEY_COMPONENT_BYTE_CEILING + }) + { + return Err(KeyStoreError::Selection( + "DSA component exceeds safety limit", + )); + } + Ok(()) +} + +fn single_pem_block(bytes: &[u8], maximum: usize) -> Result { + if bytes.len() > maximum { + return Err(KeyStoreError::Selection("PEM key exceeds resource limit")); + } + let text = std::str::from_utf8(bytes) + .map_err(|_| KeyStoreError::Selection("PEM key is not ASCII text"))? + .trim_matches(|character: char| character.is_ascii_whitespace()); + let block = pem::parse(text).map_err(|_| KeyStoreError::Selection("invalid PEM key"))?; + let begin = format!("-----BEGIN {}-----", block.tag()); + let end = format!("-----END {}-----", block.tag()); + if !text.starts_with(&begin) + || !text.ends_with(&end) + || text.matches(&begin).count() != 1 + || text.matches(&end).count() != 1 + { + return Err(KeyStoreError::Selection( + "PEM must contain one complete block", + )); + } + Ok(block) +} + +fn classify_pkcs12_error(error: ribergshamra_core::Error, limits: &Pkcs12Limits) -> KeyStoreError { + // The donor reports KDF and salt ceilings as untyped Key errors. Match + // only their exact diagnostics so wrong passwords and malformed bundles + // stay distinct; it does not expose the observed sizes. + match error { + ribergshamra_core::Error::Key(message) + if message == "PKCS#12 iterations outside configured limit" => + { + KeyStoreError::Policy(crate::policy::PolicyViolation::KdfIterationsOutsideLimit { + maximum: limits.max_iterations as usize, + }) + } + ribergshamra_core::Error::Key(message) => { + let (resource, maximum) = match message.as_str() { + "PKCS#12 salt exceeds configured size limit" => { + ("PKCS#12 salt bytes", limits.max_salt_len) + } + "PKCS#12 aggregate KDF work exceeds configured limit" => ( + crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + usize::try_from(limits.max_kdf_work).unwrap_or(usize::MAX), + ), + _ => return KeyStoreError::ProtectedContainer, + }; + KeyStoreError::Policy(crate::policy::PolicyViolation::ResourceLimitExceeded { + resource, + maximum, + }) + } + _ => KeyStoreError::ProtectedContainer, + } +} + +fn preflight_pkcs12_outer_mac_kdf( + bytes: &[u8], + limits: &Pkcs12Limits, +) -> Result<(), KeyStoreError> { + use x509_parser::der_parser::ber::{Tag, parse_ber_slice, parse_ber_u32}; + + // Preflight the outer MacData without copying or decrypting the authenticated + // safe. The full parser still enforces limits on its other KDF parameters. + let Some(iterations) = (|| { + let (trailing, pfx) = parse_ber_slice(bytes, Tag::Sequence).ok()?; + if !trailing.is_empty() { + return None; + } + let (pfx, version) = parse_ber_u32(pfx).ok()?; + if version != 3 { + return None; + } + let (pfx, _) = parse_ber_slice(pfx, Tag::Sequence).ok()?; + let (trailing, mac) = parse_ber_slice(pfx, Tag::Sequence).ok()?; + if !trailing.is_empty() { + return None; + } + let (mac, _) = parse_ber_slice(mac, Tag::Sequence).ok()?; + let (mac, _) = parse_ber_slice(mac, Tag::OctetString).ok()?; + if mac.is_empty() { + Some(1) + } else { + let (trailing, iterations) = parse_ber_u32(mac).ok()?; + trailing.is_empty().then_some(iterations) + } + })() else { + return Ok(()); + }; + if iterations == 0 || iterations > limits.max_iterations { + return Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::KdfIterationsOutsideLimit { + maximum: limits.max_iterations as usize, + }, + )); + } + if u64::from(iterations) > limits.max_kdf_work { + return Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + maximum: usize::try_from(limits.max_kdf_work).unwrap_or(usize::MAX), + }, + )); + } + Ok(()) +} + +fn enforce_pkcs8_kdf_policy( + encrypted: &EncryptedPrivateKeyInfoRef<'_>, + resources: &ResourcePolicy, +) -> Result<(), KeyStoreError> { + use pkcs8::pkcs5::{EncryptionScheme, pbes2::Kdf}; + // RFC 8018 §6.2 leaves KDF iteration policy to the application. Reject + // excessive work before decrypting attacker-supplied containers. + // https://www.rfc-editor.org/rfc/rfc8018#section-6.2 + let EncryptionScheme::Pbes2(params) = &encrypted.encryption_algorithm else { + return Err(KeyStoreError::ProtectedContainer); + }; + match ¶ms.kdf { + Kdf::Pbkdf2(kdf) => { + if kdf.iteration_count == 0 { + return Err(KeyStoreError::ProtectedContainer); + } + if u64::from(kdf.iteration_count) > resources.max_key_import_kdf_work as u64 { + return Err(kdf_policy_violation( + crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + resources.max_key_import_kdf_work, + Some(u64::from(kdf.iteration_count)), + )); + } + } + Kdf::Scrypt(kdf) => { + enforce_scrypt_kdf_limits( + kdf.cost_parameter, + u64::from(kdf.block_size), + u64::from(kdf.parallelization), + resources, + )?; + } + _ => return Err(KeyStoreError::ProtectedContainer), + } + Ok(()) +} + +fn enforce_scrypt_kdf_limits( + n: u64, + r: u64, + p: u64, + resources: &ResourcePolicy, +) -> Result<(), KeyStoreError> { + if n == 0 || r == 0 || p == 0 { + return Err(KeyStoreError::ProtectedContainer); + } + let work = n.checked_mul(r).and_then(|value| value.checked_mul(p)); + if work.is_none_or(|value| value > resources.max_key_import_kdf_work as u64) { + return Err(kdf_policy_violation( + crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + resources.max_key_import_kdf_work, + work, + )); + } + // RustCrypto scrypt retains B[p*r] plus V[N*r] and T[r] per parallel + // worker when its `parallel` feature is unified in a downstream build. + let memory = n + .checked_add(2) + .and_then(|blocks| blocks.checked_mul(p)) + .and_then(|blocks| blocks.checked_mul(r)) + .and_then(|blocks| blocks.checked_mul(128)); + if memory.is_none_or(|value| value > resources.max_key_import_kdf_memory_bytes as u64) { + return Err(kdf_policy_violation( + crate::policy::resource_name::KEY_IMPORT_KDF_MEMORY, + resources.max_key_import_kdf_memory_bytes, + memory, + )); + } + Ok(()) +} + +fn kdf_policy_violation( + resource: &'static str, + maximum: usize, + actual: Option, +) -> KeyStoreError { + KeyStoreError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource, + maximum, + actual: actual + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(usize::MAX), + }) +} + +fn element_text(node: Node<'_, '_>) -> Result { + let mut text = String::new(); + for child in node.children() { + if child.is_element() { + return Err(KeyStoreError::Invalid("unexpected nested element".into())); + } + if child.is_text() { + text.push_str(child.text().unwrap_or_default()); + } + } + Ok(text) +} + +fn decode_xml_base64(node: Node<'_, '_>) -> Result, KeyStoreError> { + let encoded = element_text(node)?; + let normalized = encoded + .bytes() + .filter(|byte| !matches!(byte, b' ' | b'\t' | b'\r' | b'\n')) + .collect::>(); + base64::engine::general_purpose::STANDARD + .decode(normalized) + .map_err(|_| KeyStoreError::Invalid("invalid key base64".into())) +} + +fn parse_xmlsec_dsa_key_value(node: Node<'_, '_>) -> Result { + let mut p = None; + let mut q = None; + let mut g = None; + let mut y = None; + let mut seed = None; + let mut counter = None; + let mut private_x = None; + let mut previous_position = None; + for child in node.children().filter(|child| child.is_element()) { + let (position, slot) = if child.has_tag_name((XMLDSIG_NS, "P")) { + (0, Some(&mut p)) + } else if child.has_tag_name((XMLDSIG_NS, "Q")) { + (1, Some(&mut q)) + } else if child.has_tag_name((XMLDSIG_NS, "G")) { + (2, Some(&mut g)) + } else if child.has_tag_name((XMLDSIG_NS, "Y")) { + (4, Some(&mut y)) + } else if child.has_tag_name((XMLSEC_NS, "X")) { + if private_x.is_some() { + return Err(KeyStoreError::Invalid("duplicate DSA X".into())); + } + // XMLDSig 1.1 §4.5.2.1 has no private X field. libxmlsec's + // keys.xml adds one before Y; only this store importer accepts it. + // https://www.w3.org/TR/xmldsig-core1/#sec-DSAKeyValue + private_x = Some(Zeroizing::new(decode_xml_base64(child)?)); + (3, None) + } else if child.has_tag_name((XMLDSIG_NS, "J")) { + (5, None) + } else if child.has_tag_name((XMLDSIG_NS, "Seed")) { + (6, Some(&mut seed)) + } else if child.has_tag_name((XMLDSIG_NS, "PgenCounter")) { + (7, Some(&mut counter)) + } else { + return Err(KeyStoreError::Invalid( + "unsupported DSAKeyValue child".into(), + )); + }; + // XMLDSig 1.1 §4.5.2.1 defines a sequence, not an unordered set. + if previous_position.is_some_and(|previous| position <= previous) { + return Err(KeyStoreError::Invalid( + "DSA parameters are out of order".into(), + )); + } + previous_position = Some(position); + if let Some(slot) = slot { + if slot.replace(decode_xml_base64(child)?).is_some() { + return Err(KeyStoreError::Invalid("duplicate DSA parameter".into())); + } + } else if position == 5 { + let _ = decode_xml_base64(child)?; + } + } + if p.is_some() != q.is_some() { + return Err(KeyStoreError::Invalid( + "DSA P and Q must occur together".into(), + )); + } + if seed.is_some() != counter.is_some() { + return Err(KeyStoreError::Invalid( + "DSA Seed and PgenCounter must occur together".into(), + )); + } + if [p.as_ref(), q.as_ref(), g.as_ref(), y.as_ref()] + .into_iter() + .flatten() + .any(|component| component.len() > crate::hard_limits::DSA_KEY_COMPONENT_BYTE_CEILING) + || private_x.as_ref().is_some_and(|component| { + component.len() > crate::hard_limits::DSA_KEY_COMPONENT_BYTE_CEILING + }) + { + return Err(KeyStoreError::Invalid( + "DSA component exceeds safety limit".into(), + )); + } + let y = y.ok_or_else(|| KeyStoreError::Invalid("DSAKeyValue requires Y".into()))?; + let private = if let Some(x) = private_x { + let (Some(p), Some(q), Some(g)) = (&p, &q, &g) else { + return Err(KeyStoreError::Invalid( + "private DSA key requires P, Q, and G".into(), + )); + }; + let components = DsaComponents::from_components( + BoxedUint::from_be_slice_vartime(p), + BoxedUint::from_be_slice_vartime(q), + BoxedUint::from_be_slice_vartime(g), + ) + .map_err(|_| KeyStoreError::Invalid("invalid DSA parameters".into()))?; + let x_value = BoxedUint::from_be_slice_vartime(&x); + let monty = BoxedMontyParams::new(components.p().clone()); + let expected_y = BoxedMontyForm::new((**components.g()).clone(), &monty) + .pow(&x_value) + .retrieve(); + if expected_y != BoxedUint::from_be_slice_vartime(&y) { + return Err(KeyStoreError::Invalid( + "DSA private and public values differ".into(), + )); + } + let public = DsaVerifyingKey::from_components(components, expected_y) + .map_err(|_| KeyStoreError::Invalid("invalid DSA public key".into()))?; + let private = NativeDsaSigningKey::from_components(public, x_value) + .map_err(|_| KeyStoreError::Invalid("invalid DSA private key".into()))?; + Some(Zeroizing::new( + private + .to_pkcs8_der() + .map_err(|_| KeyStoreError::Invalid("DSA private key encoding failed".into()))? + .as_bytes() + .to_vec(), + )) + } else { + None + }; + Ok((KeyValueInfo::Dsa { p, q, g, y }, private)) +} + +#[cfg(test)] +mod tests { + use rand_chacha::{ChaCha8Rng, rand_core::SeedableRng as _}; + use rsa::pkcs1::EncodeRsaPrivateKey as _; + + use super::*; + + fn xml_policy(resources: ResourcePolicy) -> crate::policy::VerificationPolicy { + crate::policy::VerificationPolicy { + resources, + ..crate::policy::VerificationPolicy::default() + } + } + + #[test] + fn imports_donor_pkcs12_and_rejects_wrong_password() { + // A real upstream PHAOS bundle exercises MAC, password decoding, key + // association, and certificate import rather than a synthetic ASN.1 stub. + let bytes = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_pkcs12("phaos".into(), bytes, "secret", &resources) + .expect("donor PKCS#12 should import"); + assert_eq!(inventory.private_keys.len(), 1); + assert!(!inventory.private_keys[0].certificate_chain.is_empty()); + assert!(inventory.private_keys[0].usages.allows(KeyUsage::Sign)); + + let mut wrong = KeyInventory::default(); + assert!(matches!( + wrong.add_pkcs12("phaos".into(), bytes, "wrong", &resources), + Err(KeyStoreError::ProtectedContainer) + )); + assert!(wrong.private_keys.is_empty()); + + let oversized = ResourcePolicy { + max_external_resource_bytes: bytes.len() - 1, + ..ResourcePolicy::default() + }; + assert!(matches!( + KeyInventory::default().add_pkcs12("phaos".into(), bytes, "secret", &oversized), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum, + } + )) if maximum == bytes.len() - 1 + )); + } + + #[test] + fn pkcs12_kdf_limit_is_a_typed_policy_denial() { + // A valid protected bundle that exceeds import work is not a bad password. + let bytes = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + let resources = ResourcePolicy { + max_key_import_kdf_work: 1, + ..ResourcePolicy::default() + }; + assert!(matches!( + KeyInventory::default().add_pkcs12("phaos".into(), bytes, "secret", &resources), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::KdfIterationsOutsideLimit { maximum: 1 } + )) + )); + assert!(matches!( + KeyInventory::default().add_pkcs12( + "phaos".into(), + bytes, + "wrong", + &ResourcePolicy::default(), + ), + Err(KeyStoreError::ProtectedContainer) + )); + } + + #[test] + fn pkcs12_aggregate_kdf_work_preserves_policy_error() { + // The donor exposes a distinct aggregate-work diagnostic but no count. + let limits = Pkcs12Limits { + max_kdf_work: 2, + ..Pkcs12Limits::default() + }; + assert!(matches!( + classify_pkcs12_error( + ribergshamra_core::Error::Key( + "PKCS#12 aggregate KDF work exceeds configured limit".into() + ), + &limits, + ), + KeyStoreError::Policy(crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + maximum: 2, + }) + )); + assert!(matches!( + classify_pkcs12_error(ribergshamra_core::Error::Key("other".into()), &limits), + KeyStoreError::ProtectedContainer + )); + } + + #[test] + fn pkcs12_oversized_salt_preserves_policy_error() { + // The parser's own salt ceiling must not look like a wrong password. + let limits = Pkcs12Limits::default(); + assert!(matches!( + classify_pkcs12_error( + ribergshamra_core::Error::Key("PKCS#12 salt exceeds configured size limit".into()), + &limits, + ), + KeyStoreError::Policy(crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: "PKCS#12 salt bytes", + maximum: 65_536, + }) + )); + } + + #[test] + fn private_bundle_certificates_do_not_authorize_verification() { + // A SIGN/DECRYPT-only PKCS#12 bundle must not implicitly make its + // associated leaf available as a verification lookup candidate. + let bytes = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_pkcs12("private".into(), bytes, "secret", &resources) + .expect("bundle imports"); + let leaf = inventory.private_keys()[0].certificate_chain[0].clone(); + let subject = crate::xmldsig::parse::parse_x509_certificate(&leaf) + .expect("bundle leaf parses") + .subject_dn; + let key_info = KeyInfo { + sources: vec![KeyInfoSource::X509Data(X509DataInfo { + subject_names: vec![subject], + ..X509DataInfo::default() + })], + }; + let resolver = inventory.verification_resolver(); + assert!( + resolver + .resolve(Some(&key_info), SignatureAlgorithm::RsaSha256) + .expect("lookup completes") + .is_none() + ); + + inventory + .add_certificate_der(leaf, false, &resources) + .expect("explicit lookup certificate imports"); + assert!( + inventory + .verification_resolver() + .resolve(Some(&key_info), SignatureAlgorithm::RsaSha256) + .expect("explicit lookup completes") + .is_some() + ); + } + + #[test] + fn stored_signing_keys_obey_operation_material_limits() { + // An import-time resource policy must not override stricter limits + // selected for a later signing operation. + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "hmac".into(), + SymmetricKeyKind::Hmac, + b"0123456789abcdef0123456789abcdef".to_vec(), + KeyUsages::SIGN, + &resources, + ) + .expect("HMAC imports"); + inventory + .add_private_pem( + "rsa".into(), + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-key.pem"), + None, + KeyUsages::SIGN, + &resources, + ) + .expect("RSA imports"); + + for (name, algorithm, length) in [ + ( + "hmac", + SignatureAlgorithm::HmacSha256, + inventory.symmetric_keys[0].bytes.len(), + ), + ( + "rsa", + SignatureAlgorithm::RsaSha256, + inventory.private_keys[0].pkcs8_der.len(), + ), + ] { + let mut policy = crate::policy::SigningPolicy::default(); + policy.resources.max_external_resource_bytes = length - 1; + assert!(matches!( + inventory.signing_key(name, algorithm, &policy), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum, + actual, + } + )) if maximum == length - 1 && actual == length + )); + + policy.resources.max_external_resource_bytes = length; + policy.resources.max_external_resource_total_bytes = length - 1; + assert!(matches!( + inventory.signing_key(name, algorithm, &policy), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + maximum, + actual, + } + )) if maximum == length - 1 && actual == length + )); + } + } + + #[test] + fn named_signing_lookup_obeys_active_candidate_budget() { + // Import limits do not authorize a later operation to scan the full inventory. + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + for name in ["first", "second"] { + inventory + .add_symmetric( + name.into(), + SymmetricKeyKind::Hmac, + vec![0x42; 32], + KeyUsages::SIGN, + &resources, + ) + .expect("HMAC imports"); + } + let mut policy = crate::policy::SigningPolicy::default(); + policy.resources.max_key_candidates = 1; + assert!( + inventory + .signing_key("first", SignatureAlgorithm::HmacSha256, &policy) + .is_ok() + ); + assert!(matches!( + inventory.signing_key("second", SignatureAlgorithm::HmacSha256, &policy), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::KEY_CANDIDATES, + .. + } + )) + )); + } + + #[cfg(feature = "xmlenc")] + #[test] + fn named_decryption_lookup_shares_candidate_budget_across_key_kinds() { + // Scanning a symmetric miss must consume the same operation budget as + // the subsequent private-key lookup. + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "aes".into(), + SymmetricKeyKind::Aes, + vec![0x42; 32], + KeyUsages::DECRYPT, + &resources, + ) + .expect("AES imports"); + inventory + .add_private_pem( + "rsa".into(), + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-key.pem"), + None, + KeyUsages::DECRYPT, + &resources, + ) + .expect("RSA imports"); + let mut policy = crate::policy::DecryptionPolicy::default(); + policy.resources.max_key_candidates = 1; + assert!(inventory.decryption_resolver("aes", &policy).is_ok()); + assert!(matches!( + inventory.decryption_resolver("rsa", &policy), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::KEY_CANDIDATES, + .. + } + )) + )); + } + + #[test] + fn pkcs12_imports_share_candidate_budget() { + // A key plus its retained certificate consumes two inventory slots. + let bundle = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + let resources = ResourcePolicy { + max_key_candidates: 3, + ..ResourcePolicy::default() + }; + let mut inventory = KeyInventory::default(); + inventory + .add_pkcs12("first".into(), bundle, "secret", &resources) + .expect("first bundle fits"); + assert_eq!(inventory.entry_count(), 2); + assert!( + inventory.private_keys()[0] + .matching_certificate_chain() + .is_some() + ); + assert!( + inventory + .add_pkcs12("second".into(), bundle, "secret", &resources) + .is_err() + ); + } + + #[test] + fn pkcs12_input_consumes_aggregate_import_budget() { + // Repeated containers must charge encoded bytes, even when decoded material is small. + let bundle = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_pkcs12("first".into(), bundle, "secret", &resources) + .expect("first bundle imports"); + assert!(inventory.material_bytes >= bundle.len()); + let limited = ResourcePolicy { + max_external_resource_total_bytes: bundle.len() * 2 - 1, + ..resources + }; + assert!( + inventory + .add_pkcs12("second".into(), bundle, "secret", &limited) + .is_err() + ); + } + + #[test] + fn pkcs12_unrelated_ca_does_not_block_private_key() { + // Generated with OpenSSL from the tracked RSA key and unrelated CA; + // the CA is lookup material, not a fabricated leaf certificate. + let bundle = base64::engine::general_purpose::STANDARD + .decode( + include_str!("../tests/fixtures/keys/pkcs12/rsa-key-unrelated-ca.p12.b64").trim(), + ) + .expect("fixture base64 decodes"); + let mut inventory = KeyInventory::default(); + inventory + .add_pkcs12( + "ca-only".into(), + &bundle, + "secret", + &ResourcePolicy::default(), + ) + .expect("unrelated CA must not invalidate the private key"); + assert_eq!(inventory.private_keys.len(), 1); + assert_eq!(inventory.private_keys[0].certificate_chain.len(), 1); + assert!(!inventory.private_keys[0].has_matching_leaf); + assert!( + inventory + .signing_key( + "ca-only", + SignatureAlgorithm::RsaSha256, + &crate::policy::SigningPolicy::default(), + ) + .is_ok() + ); + } + + #[test] + fn pkcs12_identical_leaf_bags_are_one_candidate() { + // OpenSSL exported the same certificate as both the leaf and an extra + // cert bag; the inventory retains one copy for the matching key. + let bundle = base64::engine::general_purpose::STANDARD + .decode(include_str!("../tests/fixtures/keys/pkcs12/rsa-duplicate-leaf.p12.b64").trim()) + .expect("fixture base64 decodes"); + let mut inventory = KeyInventory::default(); + inventory + .add_pkcs12( + "duplicate-leaf".into(), + &bundle, + "secret", + &ResourcePolicy::default(), + ) + .expect("identical leaf bags are not ambiguous"); + assert_eq!(inventory.private_keys[0].certificate_chain.len(), 1); + assert!(inventory.private_keys[0].has_matching_leaf); + } + + #[test] + fn ec_pkcs12_import_is_sign_only() { + // The convenience importer must not advertise RSA transport for EC. + let bundle = base64::engine::general_purpose::STANDARD + .decode(include_str!("../tests/fixtures/keys/pkcs12/ec-key.p12.b64").trim()) + .expect("fixture base64 decodes"); + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_pkcs12("ec".into(), &bundle, "secret", &resources) + .expect("EC signing bundle imports"); + assert!(inventory.private_keys()[0].usages.allows(KeyUsage::Sign)); + assert!(!inventory.private_keys()[0].usages.allows(KeyUsage::Decrypt)); + assert!( + KeyInventory::default() + .add_pkcs12_with_usages( + "ec".into(), + &bundle, + "secret", + KeyUsages::DECRYPT, + &resources, + ) + .is_err() + ); + } + + #[test] + fn password_callback_runs_for_protected_container() { + // Password delivery is caller-owned and failures must not leak the + // callback's diagnostic or retry a plaintext decoder. + let bundle = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + let mut inventory = KeyInventory::default(); + inventory + .add_pkcs12_with_password_callback( + "bundle".into(), + bundle, + || Some(Zeroizing::new("secret".to_owned())), + KeyUsages::SIGN, + &ResourcePolicy::default(), + ) + .expect("callback password decrypts donor bundle"); + assert!(inventory.private_keys[0].usages.allows(KeyUsage::Sign)); + assert!(matches!( + KeyInventory::default().add_pkcs12_with_password_callback( + "bundle".into(), + bundle, + || None, + KeyUsages::SIGN, + &ResourcePolicy::default(), + ), + Err(KeyStoreError::ProtectedContainer) + )); + let limited = ResourcePolicy { + max_external_resource_total_bytes: bundle.len() - 1, + ..ResourcePolicy::default() + }; + assert!(matches!( + KeyInventory::default().add_pkcs12_with_password_callback( + "bundle".into(), + bundle, + || panic!("budget must be checked before password delivery"), + KeyUsages::SIGN, + &limited, + ), + Err(KeyStoreError::Selection(_)) + )); + let limited_kdf = ResourcePolicy { + max_key_import_kdf_work: 1, + ..ResourcePolicy::default() + }; + assert!(matches!( + KeyInventory::default().add_pkcs12_with_password_callback( + "bundle".into(), + bundle, + || panic!("KDF denial must precede password delivery"), + KeyUsages::SIGN, + &limited_kdf, + ), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::KdfIterationsOutsideLimit { maximum: 1 } + )) + )); + let oversized_bundle = ResourcePolicy { + max_external_resource_bytes: bundle.len() - 1, + ..ResourcePolicy::default() + }; + assert!(matches!( + KeyInventory::default().add_pkcs12_with_password_callback( + "bundle".into(), + bundle, + || panic!("size denial must precede password delivery"), + KeyUsages::SIGN, + &oversized_bundle, + ), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimitExceeded { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum, + } + )) if maximum == bundle.len() - 1 + )); + } + + #[test] + fn pkcs12_callback_preflights_indefinite_outer_ber() { + // The outer PFX may use BER indefinite length without changing MAC parameters. + let bundle = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + assert_eq!(bundle[0], 0x30); + let length_octets = usize::from(bundle[1] & 0x7f); + assert!(bundle[1] & 0x80 != 0 && length_octets > 0); + let mut ber = vec![0x30, 0x80]; + ber.extend_from_slice(&bundle[2 + length_octets..]); + ber.extend_from_slice(&[0, 0]); + let resources = ResourcePolicy { + max_key_import_kdf_work: 1, + ..ResourcePolicy::default() + }; + assert!(matches!( + KeyInventory::default().add_pkcs12_with_password_callback( + "bundle".into(), + &ber, + || panic!("BER MAC KDF denial must precede password delivery"), + KeyUsages::SIGN, + &resources, + ), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::KdfIterationsOutsideLimit { maximum: 1 } + )) + )); + } + + #[test] + fn donor_xml_store_preserves_private_dsa_material() { + // xmlsec's non-standard DSA X must be checked against Y, not silently + // dropped while presenting the named key as usable for signing. + let bytes = include_bytes!("../tests/fixtures/keys/xmlsec/mixed-keys.xml"); + let inventory = KeyInventory::from_xml_bytes( + bytes, + &xml_policy(ResourcePolicy::default()), + XmlBackend::default(), + ) + .expect("donor key store should parse"); + let dsa = inventory + .private_keys + .iter() + .find(|entry| entry.name == "test-dsa") + .expect("DSA private X should be imported"); + assert!(dsa.usages.allows(KeyUsage::Sign)); + assert!(!dsa.usages.allows(KeyUsage::Decrypt)); + assert!(DsaSigningKey::from_pkcs8_der(&dsa.pkcs8_der).is_ok()); + let dsa_public = inventory + .public_keys() + .iter() + .find(|entry| entry.name == "test-dsa") + .expect("DSA public entry is retained"); + assert_eq!(dsa_public.usages, KeyUsages::VERIFY); + } + + #[test] + fn xml_store_charges_retained_decoded_material() { + // DSA retains public components and a derived private PKCS#8 buffer. + let source = include_str!("../tests/fixtures/keys/xmlsec/mixed-keys.xml"); + let marker = source.find("test-dsa").expect("DSA key"); + let start = source[..marker].rfind("").expect("DSA end") + "".len(); + let xml = format!( + "{}", + source[start..end].replace('\n', "") + ); + let inventory = KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(ResourcePolicy::default()), + XmlBackend::default(), + ) + .expect("compact DSA store imports"); + let retained = inventory.retained_material_bytes().expect("bounded tally"); + assert_eq!(inventory.material_bytes, xml.len().max(retained)); + assert!(retained >= inventory.private_keys[0].pkcs8_der.len()); + } + + #[test] + fn xml_store_rejects_empty_symmetric_material() { + // Empty decoded secrets are invalid at the same boundary as direct imports. + for kind in ["HMACKeyValue", "AESKeyValue", "DESKeyValue"] { + let xml = format!( + "empty<{kind} xmlns=\"{XMLSEC_NS}\"/>" + ); + assert!( + KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(ResourcePolicy::default()), + XmlBackend::default(), + ) + .is_err() + ); + } + } + + #[test] + fn aes_imports_require_supported_key_widths() { + // Both direct and XML key stores must reject widths unusable by AES-CBC/GCM. + let resources = ResourcePolicy::default(); + for length in [1, 15, 17, 23, 25, 31, 33] { + let mut inventory = KeyInventory::default(); + assert!( + inventory + .add_symmetric( + "aes".into(), + SymmetricKeyKind::Aes, + vec![0; length], + KeyUsages::ENCRYPT, + &resources, + ) + .is_err() + ); + let xml = format!( + "aes{}", + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, vec![0; length]) + ); + assert!( + KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(resources.clone()), + XmlBackend::default(), + ) + .is_err() + ); + } + for length in [16, 24, 32] { + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "aes".into(), + SymmetricKeyKind::Aes, + vec![0; length], + KeyUsages::ENCRYPT, + &resources, + ) + .expect("AES-128/192/256 key imports"); + } + } + + #[test] + fn unsupported_des_material_is_not_authorized() { + // A parsed legacy DES value must not advertise an operation that the + // encryption pipeline cannot execute. + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + assert!( + inventory + .add_symmetric( + "des".into(), + SymmetricKeyKind::Des, + vec![0x42; 8], + KeyUsages::ENCRYPT, + &resources, + ) + .is_err() + ); + let xml = format!( + "desQkJCQkJCQkI=" + ); + assert!( + KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(resources.clone()), + XmlBackend::default(), + ) + .is_err() + ); + } + + #[test] + fn xml_store_accepts_xmlsig11_ec_key_value() { + // XMLDSig 1.1 ECKeyValue must be recognized as public material. + let pem = pem::parse(include_bytes!( + "../tests/fixtures/keys/ec/ec-prime256v1-pubkey.pem" + )) + .expect("EC public PEM decodes"); + let (_, spki) = x509_parser::x509::SubjectPublicKeyInfo::from_der(pem.contents()) + .expect("EC SPKI parses"); + let point = + base64::engine::general_purpose::STANDARD.encode(spki.subject_public_key.data.as_ref()); + let xml = format!( + "ec{point}" + ); + let store = KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(ResourcePolicy::default()), + XmlBackend::default(), + ) + .expect("ECKeyValue namespace is supported"); + assert_eq!(store.public_keys().len(), 1); + assert_eq!(store.public_keys()[0].usages, KeyUsages::VERIFY); + assert!( + store.public_keys()[0] + .key_info + .sources + .iter() + .any(|source| matches!(source, KeyInfoSource::KeyValue(KeyValueInfo::Ec { .. }))) + ); + } + + #[test] + fn xml_store_rejects_unusable_public_key_values() { + // Malformed supported public-key material must fail at import, not at verification. + let cases = [ + "AQAB", + "AQ==", + ]; + for key in cases { + let xml = format!( + "invalid{key}" + ); + assert!( + KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(ResourcePolicy::default()), + XmlBackend::default(), + ) + .is_err() + ); + } + } + + #[test] + fn xml_store_enforces_all_parser_resource_limits() { + // Import must not skip the depth, namespace or cumulative work limits. + let xml = format!( + "keyc2VjcmV0" + ); + for resources in [ + ResourcePolicy { + max_xml_depth: 2, + ..ResourcePolicy::default() + }, + ResourcePolicy { + max_xml_namespace_bindings: 1, + ..ResourcePolicy::default() + }, + ResourcePolicy { + max_xml_parse_work_bytes: 1, + ..ResourcePolicy::default() + }, + ] { + assert!( + KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(resources), + XmlBackend::default() + ) + .is_err(), + "parser limit must apply to key stores" + ); + } + } + + #[test] + fn xml_store_bounds_source_before_utf16_decode() { + // The source-byte ceiling must be checked before UTF-16 expansion or parsing. + let xml = format!(""); + let mut bytes = vec![0xff, 0xfe]; + for unit in xml.encode_utf16() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + let resources = ResourcePolicy { + max_xml_document_bytes: xml.len() + 1, + ..ResourcePolicy::default() + }; + assert!(bytes.len() > resources.max_xml_document_bytes); + assert!( + KeyInventory::from_xml_bytes(&bytes, &xml_policy(resources), XmlBackend::default()) + .is_err() + ); + } + + #[test] + fn xml_store_uses_operation_xml_policy() { + // Internal DTD permission must come from the operation snapshot, not + // an importer-local default that rejects a caller-authorized store. + let xml = format!( + "]>&key;c2VjcmV0" + ); + let denied = crate::policy::VerificationPolicy::default(); + assert!( + KeyInventory::from_xml_bytes(xml.as_bytes(), &denied, XmlBackend::default()).is_err() + ); + let mut allowed = denied; + allowed.xml.allow_internal_dtd = true; + assert!( + KeyInventory::from_xml_bytes(xml.as_bytes(), &allowed, XmlBackend::default()).is_ok() + ); + } + + #[test] + fn ec_public_key_cannot_authorize_encryption() { + // EC material can verify signatures but cannot serve as an RSA recipient. + let resources = ResourcePolicy::default(); + let ec = include_bytes!("../tests/fixtures/keys/ec/ec-prime256v1-pubkey.pem"); + let cert = include_bytes!("../tests/fixtures/keys/ec/ec-prime256v1-cert.pem"); + let mut store = KeyInventory::default(); + store + .add_public_pem("ec".into(), ec, &resources) + .expect("EC public key imports"); + assert_eq!(store.public_keys()[0].usages, KeyUsages::VERIFY); + assert!( + store + .add_public_pem_with_usages("ec-encrypt".into(), ec, KeyUsages::ENCRYPT, &resources) + .is_err() + ); + let cert_der = pem::parse(cert) + .expect("EC certificate PEM decodes") + .into_contents(); + store + .add_public_der("ec-cert".into(), cert_der.clone(), &resources) + .expect("EC certificate imports"); + assert_eq!(store.public_keys()[1].usages, KeyUsages::VERIFY); + assert!( + store + .add_public_der_with_usages( + "ec-cert-encrypt".into(), + cert_der, + KeyUsages::ENCRYPT, + &resources + ) + .is_err() + ); + } + + #[test] + fn xml_store_rejects_policy_above_absolute_ceiling() { + // Public imports cannot bypass hard ceilings with an unvalidated policy. + let resources = ResourcePolicy { + max_xml_nodes: crate::hard_limits::XML_DOCUMENT_NODE_CEILING as usize + 1, + ..ResourcePolicy::default() + }; + let xml = format!(""); + assert!( + KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(resources.clone()), + XmlBackend::default(), + ) + .is_err() + ); + assert!( + KeyInventory::default() + .add_symmetric( + "key".into(), + SymmetricKeyKind::Hmac, + b"secret".to_vec(), + KeyUsages::SIGN, + &resources, + ) + .is_err() + ); + } + + #[test] + fn ec_private_key_cannot_advertise_rsa_decryption() { + // Only RSA private material can satisfy the inventory's decrypt API. + let pem = include_bytes!("../tests/fixtures/keys/ec/ec-prime256v1-key.pem"); + assert!( + KeyInventory::default() + .add_private_pem( + "ec".into(), + pem, + None, + KeyUsages::DECRYPT, + &ResourcePolicy::default(), + ) + .is_err() + ); + } + + #[test] + fn imports_rsa_pkcs1_pem_and_resolves_named_spki() { + // Traditional RSA import and named public lookup share one inventory, + // while the resolver still applies the operation's verification policy. + let private_pem = include_str!("../tests/fixtures/keys/rsa/rsa-2048-key.pem"); + let rsa = RsaPrivateKey::from_pkcs8_pem(private_pem).expect("RSA fixture parses"); + let pkcs1 = rsa.to_pkcs1_der().expect("RSA fixture encodes as PKCS#1"); + let public = rsa + .to_public_key() + .to_public_key_der() + .expect("RSA fixture has SPKI"); + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_private_der( + "key".into(), + pkcs1.as_bytes(), + None, + KeyUsages::SIGN.union(KeyUsages::DECRYPT), + &resources, + ) + .expect("PKCS#1 private key imports"); + inventory + .add_public_der("verify-key".into(), public.as_bytes().to_vec(), &resources) + .expect("public SPKI imports"); + assert!(inventory.private_keys[0].usages.allows(KeyUsage::Sign)); + let mut info = KeyInfo::default(); + info.sources + .push(KeyInfoSource::KeyName("verify-key".into())); + let resolver = inventory.verification_resolver(); + let resolved = resolver + .resolve(Some(&info), SignatureAlgorithm::RsaSha256) + .expect("named public key resolves"); + assert!(resolved.is_some()); + } + + #[test] + fn rejects_unknown_pem_text_and_duplicate_names() { + // PEM is a single armor block; unrelated trailing data must not be + // silently skipped by the general-purpose PEM parser. + let public = include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"); + let mut inventory = KeyInventory::default(); + let resources = ResourcePolicy::default(); + inventory + .add_public_pem("first".into(), public, &resources) + .expect("public PEM imports"); + assert!(matches!( + inventory.add_public_pem("first".into(), public, &resources), + Err(KeyStoreError::Selection("duplicate key name")) + )); + let mut trailing = public.to_vec(); + trailing.extend_from_slice(b"\nnot a key"); + assert!( + inventory + .add_public_pem("other".into(), &trailing, &resources) + .is_err() + ); + } + + #[test] + fn rsa_spki_import_rejects_even_public_exponent() { + // ASN.1 shape alone must not grant verify/encrypt usages to an unusable RSA key. + let mut der = single_pem_block( + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"), + ResourcePolicy::default().max_external_resource_bytes, + ) + .expect("public key fixture") + .into_contents(); + let exponent = der + .windows(5) + .position(|bytes| bytes == [0x02, 0x03, 0x01, 0x00, 0x01]) + .expect("RSA exponent in SPKI"); + der[exponent + 4] = 0; + assert!( + KeyInventory::default() + .add_public_der("invalid".into(), der, &ResourcePolicy::default()) + .is_err() + ); + } + + #[test] + fn rsa_public_pkcs1_pem_is_bounded_before_bigint_decode() { + // The borrowed ASN.1 modulus is checked before RSA allocates integers. + let modulus = vec![1_u8; crate::hard_limits::RSA_MODULUS_BIT_CEILING.div_ceil(8) + 1]; + let exponent = [1_u8, 0, 1]; + let public = rsa::pkcs1::RsaPublicKey { + modulus: der::asn1::UintRef::new(&modulus).expect("valid modulus"), + public_exponent: der::asn1::UintRef::new(&exponent).expect("valid exponent"), + }; + let pem = pem::encode(&pem::Pem::new( + "RSA PUBLIC KEY", + der::Encode::to_der(&public).expect("encodable RSA public key"), + )); + assert!(matches!( + KeyInventory::default().add_public_pem( + "oversized".into(), + pem.as_bytes(), + &ResourcePolicy::default(), + ), + Err(KeyStoreError::Selection( + "RSA public key exceeds safety limit" + )) + )); + } + + #[test] + fn direct_inventory_names_consume_resource_budget() { + // Caller-owned names must not bypass per-resource or retained aggregate bounds. + let resources = ResourcePolicy { + max_external_resource_bytes: 64, + max_external_resource_total_bytes: 100, + ..ResourcePolicy::default() + }; + let mut inventory = KeyInventory::default(); + assert!( + inventory + .add_symmetric( + "x".repeat(65), + SymmetricKeyKind::Hmac, + vec![7; 32], + KeyUsages::SIGN, + &resources, + ) + .is_err() + ); + inventory + .add_symmetric( + "a".repeat(40), + SymmetricKeyKind::Hmac, + vec![7; 32], + KeyUsages::SIGN, + &resources, + ) + .expect("first named key fits"); + assert!( + inventory + .add_symmetric( + "b".repeat(40), + SymmetricKeyKind::Hmac, + vec![8; 32], + KeyUsages::SIGN, + &resources, + ) + .is_err() + ); + } + + #[test] + fn encrypted_pkcs8_requires_correct_password_without_plaintext_fallback() { + // Wrong passwords must not retry another format or leave a partial + // registration in the caller-owned inventory. + let private_pem = include_str!("../tests/fixtures/keys/rsa/rsa-2048-key.pem"); + let rsa = RsaPrivateKey::from_pkcs8_pem(private_pem).expect("RSA fixture parses"); + let plain = rsa.to_pkcs8_der().expect("RSA fixture encodes as PKCS#8"); + let mut rng = ChaCha8Rng::seed_from_u64(0xA11C_E501); + let encrypted = PrivateKeyInfoRef::try_from(plain.as_bytes()) + .expect("PKCS#8 reference parses") + .encrypt_with_rng(&mut rng, b"correct") + .expect("PKCS#8 fixture encrypts"); + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + let restricted = ResourcePolicy { + max_key_import_kdf_work: 1, + ..ResourcePolicy::default() + }; + assert!(matches!( + inventory.add_private_der( + "restricted".into(), + encrypted.as_bytes(), + Some(b"correct"), + KeyUsages::SIGN, + &restricted, + ), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + maximum: 1, + .. + } + )) + )); + let callback_calls = std::cell::Cell::new(0); + assert!(matches!( + inventory.add_private_der_with_password_callback( + "restricted-callback".into(), + encrypted.as_bytes(), + || { + callback_calls.set(callback_calls.get() + 1); + Some(Zeroizing::new(b"correct".to_vec())) + }, + KeyUsages::SIGN, + &restricted, + ), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::KEY_IMPORT_KDF_WORK, + maximum: 1, + .. + } + )) + )); + assert_eq!(callback_calls.get(), 0); + assert!(matches!( + inventory.add_private_der( + "protected".into(), + encrypted.as_bytes(), + Some(b"wrong"), + KeyUsages::SIGN, + &resources, + ), + Err(KeyStoreError::ProtectedContainer) + )); + assert!(inventory.private_keys.is_empty()); + inventory + .add_private_der( + "protected".into(), + encrypted.as_bytes(), + Some(b"correct"), + KeyUsages::SIGN, + &resources, + ) + .expect("correct password imports encrypted PKCS#8"); + assert_eq!(inventory.private_keys.len(), 1); + assert!(inventory.material_bytes >= encrypted.as_bytes().len()); + let mut callback_inventory = KeyInventory::default(); + callback_inventory + .add_private_der_with_password_callback( + "callback".into(), + encrypted.as_bytes(), + || Some(Zeroizing::new(b"correct".to_vec())), + KeyUsages::SIGN, + &resources, + ) + .expect("callback decrypts encrypted PKCS#8"); + callback_inventory + .add_private_der_with_password_callback( + "plain".into(), + plain.as_bytes(), + || panic!("plaintext PKCS#8 must not request a password"), + KeyUsages::SIGN, + &resources, + ) + .expect("plaintext import ignores password callback"); + } + + #[test] + fn scrypt_parallel_buffers_are_checked_before_derivation() { + // N*r fits a tiny limit, but p independent B/V/T workspaces do not. + let mut resources = ResourcePolicy { + max_key_import_kdf_work: 10_000, + max_key_import_kdf_memory_bytes: 4_096, + ..ResourcePolicy::default() + }; + assert!(matches!( + enforce_scrypt_kdf_limits(2, 1, 1_000, &resources), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::KEY_IMPORT_KDF_MEMORY, + maximum: 4_096, + actual: 512_000, + } + )) + )); + resources.max_key_import_kdf_memory_bytes = 512_000; + assert!(enforce_scrypt_kdf_limits(2, 1, 1_000, &resources).is_ok()); + } + + #[test] + fn named_hmac_resolution_enforces_usage_and_method() { + // A named secret may verify only when both its usage and the XMLDSig + // method permit HMAC; the resolver must not fall back to another key. + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "verify".into(), + SymmetricKeyKind::Hmac, + b"sufficiently-long-hmac-secret".to_vec(), + KeyUsages::VERIFY, + &resources, + ) + .expect("verification HMAC imports"); + inventory + .add_symmetric( + "sign-only".into(), + SymmetricKeyKind::Hmac, + b"another-hmac-secret".to_vec(), + KeyUsages::SIGN, + &resources, + ) + .expect("sign-only HMAC imports"); + let resolver = inventory.verification_resolver(); + let named = |name: &str| KeyInfo { + sources: vec![KeyInfoSource::KeyName(name.into())], + ..KeyInfo::default() + }; + assert!( + resolver + .resolve(Some(&named("verify")), SignatureAlgorithm::HmacSha256) + .expect("named HMAC resolves") + .is_some() + ); + assert!( + resolver + .resolve(Some(&named("verify")), SignatureAlgorithm::RsaSha256) + .is_err() + ); + assert!( + resolver + .resolve(Some(&named("sign-only")), SignatureAlgorithm::HmacSha256) + .is_err() + ); + } + + #[test] + fn named_hmac_resolution_uses_active_resource_limits() { + // A store imported under a broad policy must not bypass a later, + // stricter verification snapshot when resolving a named secret. + let resources = ResourcePolicy::default(); + let secret = b"sufficiently-long-hmac-secret"; + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "verify".into(), + SymmetricKeyKind::Hmac, + secret.to_vec(), + KeyUsages::VERIFY, + &resources, + ) + .expect("verification HMAC imports"); + let key_info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("verify".into())], + ..KeyInfo::default() + }; + let resolver = inventory.verification_resolver(); + for (resource, aggregate) in [ + (crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, false), + ( + crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + true, + ), + ] { + let mut policy = crate::policy::VerificationPolicy::default(); + if aggregate { + policy.resources.max_external_resource_total_bytes = secret.len() - 1; + } else { + policy.resources.max_external_resource_bytes = secret.len() - 1; + } + let error = resolver + .resolve_with_policy(Some(&key_info), SignatureAlgorithm::HmacSha256, &policy) + .err() + .expect("active limit must reject stored HMAC material"); + assert!( + matches!( + error, + DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: actual, + maximum, + actual: size, + }) if actual == resource && maximum == secret.len() - 1 && size == secret.len() + ), + "{error:?}" + ); + } + } + + #[test] + fn one_named_verification_entry_fits_one_candidate() { + // A name and the single entry it selects are one lookup, not two. + let mut inventory = KeyInventory::default(); + let mut policy = crate::policy::VerificationPolicy::default(); + inventory + .add_symmetric( + "only".into(), + SymmetricKeyKind::Hmac, + b"sufficiently-long-hmac-secret".to_vec(), + KeyUsages::VERIFY, + &policy.resources, + ) + .expect("HMAC imports"); + policy.resources.max_key_candidates = 1; + let info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("only".into())], + }; + assert!( + inventory + .verification_resolver() + .resolve_with_policy(Some(&info), SignatureAlgorithm::HmacSha256, &policy) + .expect("one lookup fits") + .is_some() + ); + } + + #[test] + fn pem_imports_charge_encoded_input_to_aggregate_budget() { + // Repeated padded PEM inputs must consume the aggregate work budget + // even when their decoded DER keys are much smaller. + let public = include_str!("../tests/fixtures/keys/rsa/rsa-4096-pubkey.pem"); + let private = include_str!("../tests/fixtures/keys/rsa/rsa-4096-key.pem"); + for (pem, is_private) in [(public, false), (private, true)] { + let padded = format!("{pem}{}", " ".repeat(16 * 1024)); + let resources = ResourcePolicy { + max_external_resource_bytes: padded.len(), + max_external_resource_total_bytes: padded.len() + + if is_private { 5 } else { 10 } + + 1, + ..ResourcePolicy::default() + }; + let mut inventory = KeyInventory::default(); + let import = |inventory: &mut KeyInventory, name: &str| { + if is_private { + inventory.add_private_pem( + name.into(), + padded.as_bytes(), + None, + KeyUsages::SIGN, + &resources, + ) + } else { + inventory.add_public_pem(name.into(), padded.as_bytes(), &resources) + } + }; + import(&mut inventory, "first").expect("first PEM import fits"); + assert!( + matches!( + import(&mut inventory, "second"), + Err(KeyStoreError::Selection( + "key material total exceeds resource limit" + )) + ), + "second PEM input must exceed aggregate budget" + ); + } + } + + #[test] + fn repeated_key_name_selects_one_inventory_entry() { + // XMLDSig 1.1 section 4.5 permits repeated KeyInfo choices; duplicate + // references to one entry are not two distinct verification keys. + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "secret".into(), + SymmetricKeyKind::Hmac, + b"sufficiently-long-hmac-secret".to_vec(), + KeyUsages::VERIFY, + &resources, + ) + .expect("HMAC key imports"); + inventory + .add_symmetric( + "other-secret".into(), + SymmetricKeyKind::Hmac, + b"another-long-hmac-secret".to_vec(), + KeyUsages::VERIFY, + &resources, + ) + .expect("second HMAC key imports"); + inventory + .add_public_pem( + "public".into(), + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"), + &resources, + ) + .expect("public key imports"); + let resolver = inventory.verification_resolver(); + for (name, algorithm) in [ + ("secret", SignatureAlgorithm::HmacSha256), + ("public", SignatureAlgorithm::RsaSha256), + ] { + let info = KeyInfo { + sources: vec![ + KeyInfoSource::KeyName(name.into()), + KeyInfoSource::KeyName(name.into()), + ], + }; + assert!(resolver.resolve(Some(&info), algorithm).is_ok(), "{name}"); + } + let distinct = KeyInfo { + sources: vec![ + KeyInfoSource::KeyName("secret".into()), + KeyInfoSource::KeyName("other-secret".into()), + ], + }; + assert!( + resolver + .resolve(Some(&distinct), SignatureAlgorithm::HmacSha256) + .is_err() + ); + } + + #[test] + fn named_lookup_charges_inspected_inventory_entries() { + // A late KeyName must not bypass a stricter operation candidate limit. + let mut inventory = KeyInventory::default(); + let resources = ResourcePolicy::default(); + for name in ["first", "second"] { + inventory + .add_symmetric( + name.into(), + SymmetricKeyKind::Hmac, + b"sufficiently-long-hmac-secret".to_vec(), + KeyUsages::VERIFY, + &resources, + ) + .expect("key imports"); + } + let key_info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("second".into())], + ..KeyInfo::default() + }; + let policy = crate::policy::VerificationPolicy { + resources: ResourcePolicy { + max_key_candidates: 1, + ..ResourcePolicy::default() + }, + ..crate::policy::VerificationPolicy::default() + }; + let resolver = inventory.verification_resolver(); + assert!( + resolver + .resolve_with_policy_and_provider( + Some(&key_info), + SignatureAlgorithm::HmacSha256, + &policy, + crate::provider::default_provider(), + ) + .is_err() + ); + } + + #[test] + fn private_import_rejects_public_only_usage() { + // Usage is part of the inventory contract: nonsensical permissions + // must not survive import and later be interpreted by a resolver. + let private = include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-key.pem"); + let mut inventory = KeyInventory::default(); + assert!( + inventory + .add_private_pem( + "wrong-use".into(), + private, + None, + KeyUsages::SIGN.union(KeyUsages::VERIFY), + &ResourcePolicy::default(), + ) + .is_err() + ); + assert!(inventory.private_keys.is_empty()); + } + + #[test] + fn normalized_private_key_must_fit_resource_limit() { + // PKCS#8 wrapping may cross the per-resource ceiling even when PKCS#1 fits. + let rsa = RsaPrivateKey::from_pkcs8_pem(include_str!( + "../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("RSA fixture parses"); + let pkcs1 = rsa.to_pkcs1_der().expect("PKCS#1 encodes"); + let pkcs8 = rsa.to_pkcs8_der().expect("PKCS#8 encodes"); + assert!(pkcs8.as_bytes().len() > pkcs1.as_bytes().len()); + let resources = ResourcePolicy { + max_external_resource_bytes: pkcs1.as_bytes().len(), + ..ResourcePolicy::default() + }; + let mut inventory = KeyInventory::default(); + assert!( + inventory + .add_private_der( + "rsa".into(), + pkcs1.as_bytes(), + None, + KeyUsages::SIGN, + &resources, + ) + .is_err() + ); + assert!(inventory.private_keys.is_empty()); + } + + #[test] + fn oversized_rsa_components_are_rejected_before_private_key_decode() { + // PKCS#8 wraps PKCS#1 integers; every component must be checked + // before RustCrypto allocates big integers or validates CRT arithmetic. + let pem = include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-key.pem"); + let block = single_pem_block(pem, ResourcePolicy::default().max_external_resource_bytes) + .expect("fixture PEM"); + let info = PrivateKeyInfoRef::try_from(block.contents()).expect("fixture PKCS#8"); + let original = rsa::pkcs1::RsaPrivateKey::from_der(info.private_key.as_bytes()) + .expect("fixture PKCS#1"); + let oversized_modulus = vec![1_u8; 1025]; + let oversized = rsa::pkcs1::RsaPrivateKey { + modulus: der::asn1::UintRef::new(&oversized_modulus).expect("positive modulus"), + public_exponent: original.public_exponent, + private_exponent: original.private_exponent, + prime1: original.prime1, + prime2: original.prime2, + exponent1: original.exponent1, + exponent2: original.exponent2, + coefficient: original.coefficient, + other_prime_infos: None, + }; + let pkcs1 = der::Encode::to_der(&oversized).expect("PKCS#1 encodes"); + let octets = der::asn1::OctetStringRef::new(&pkcs1).expect("PKCS#8 octets"); + let pkcs8 = der::Encode::to_der(&PrivateKeyInfoRef::new(rsa::pkcs1::ALGORITHM_ID, octets)) + .expect("PKCS#8 encodes"); + let mut inventory = KeyInventory::default(); + let error = inventory + .add_private_der( + "oversized".into(), + &pkcs8, + None, + KeyUsages::SIGN, + &ResourcePolicy::default(), + ) + .expect_err("oversized RSA modulus must fail at preflight"); + assert!(error.to_string().contains("safety limit"), "{error}"); + + let oversized_exponent = vec![1_u8; 1025]; + let oversized = rsa::pkcs1::RsaPrivateKey { + modulus: original.modulus, + public_exponent: original.public_exponent, + private_exponent: der::asn1::UintRef::new(&oversized_exponent) + .expect("positive exponent"), + prime1: original.prime1, + prime2: original.prime2, + exponent1: original.exponent1, + exponent2: original.exponent2, + coefficient: original.coefficient, + other_prime_infos: None, + }; + let pkcs1 = der::Encode::to_der(&oversized).expect("PKCS#1 encodes"); + let error = preflight_rsa_pkcs1_components(&pkcs1) + .expect_err("oversized private exponent must fail before bigint decoding"); + assert!(error.to_string().contains("safety limit"), "{error}"); + } + + #[test] + fn named_certificate_import_is_not_a_trust_anchor() { + // A certificate is usable as a named public-key source but importing + // it must never grant certificate-chain trust implicitly. + let pem = include_bytes!("../tests/fixtures/keys/rsa/rsa-4096-cert.pem"); + let certificate = + single_pem_block(pem, ResourcePolicy::default().max_external_resource_bytes) + .expect("certificate fixture is one PEM block"); + let mut inventory = KeyInventory::default(); + inventory + .add_public_der( + "recipient-cert".into(), + certificate.contents().to_vec(), + &ResourcePolicy::default(), + ) + .expect("named X.509 certificate imports"); + assert!( + inventory + .rsa_encryption_key( + "recipient-cert", + &crate::policy::EncryptionPolicy::default() + ) + .is_ok() + ); + let restricted = crate::policy::EncryptionPolicy { + resources: ResourcePolicy { + max_external_resource_bytes: 64, + max_external_resource_total_bytes: 64, + ..ResourcePolicy::default() + }, + ..crate::policy::EncryptionPolicy::default() + }; + assert!( + inventory + .rsa_encryption_key("recipient-cert", &restricted) + .is_err() + ); + assert!(inventory.trusted_certificates.is_empty()); + } + + #[test] + fn unsupported_spki_is_rejected_at_import() { + // A syntactically valid Ed25519 SPKI must not acquire VERIFY usage. + let mut ed25519_spki = vec![ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, + ]; + ed25519_spki.extend_from_slice(&[1; 32]); + let mut inventory = KeyInventory::default(); + assert!( + inventory + .add_public_der( + "unsupported".into(), + ed25519_spki, + &ResourcePolicy::default() + ) + .is_err() + ); + assert!(inventory.public_keys.is_empty()); + } + + #[test] + fn named_certificate_resolution_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_certificate_and_anchors_share_aggregate_budget() { + // Selected named material and configured trust material are one + // operation, even though they enter the resolver through separate paths. + let certificate = single_pem_block( + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-cert.pem"), + ResourcePolicy::default().max_external_resource_bytes, + ) + .expect("leaf PEM") + .into_contents(); + let anchor = single_pem_block( + include_bytes!("../tests/fixtures/keys/rsa/rsa-4096-cert.pem"), + ResourcePolicy::default().max_external_resource_bytes, + ) + .expect("anchor PEM") + .into_contents(); + let mut inventory = KeyInventory::default(); + inventory + .add_public_der( + "leaf".into(), + certificate.clone(), + &ResourcePolicy::default(), + ) + .expect("leaf imports"); + inventory + .add_certificate_der(anchor.clone(), true, &ResourcePolicy::default()) + .expect("anchor imports"); + let mut policy = crate::policy::VerificationPolicy::default(); + policy.key_trust.verify_x509_chains = true; + policy.resources.max_external_resource_total_bytes = certificate.len() + anchor.len() - 1; + let info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("leaf".into())], + }; + let error = inventory + .verification_resolver() + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::RsaSha256, + &policy, + crate::provider::default_provider(), + ) + .err() + .expect("combined selected and configured material exceeds the budget"); + assert!(matches!( + error, + DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + .. + }) + )); + } + + #[test] + fn named_key_does_not_bypass_other_source_permissions() { + // Every source in a document KeyInfo is subject to the policy, even + // when the inventory can resolve its KeyName without the other source. + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_symmetric( + "named".into(), + SymmetricKeyKind::Hmac, + b"verification-secret".to_vec(), + KeyUsages::VERIFY, + &resources, + ) + .expect("HMAC fixture imports"); + let info = KeyInfo { + sources: vec![ + KeyInfoSource::KeyName("named".into()), + KeyInfoSource::KeyValue(KeyValueInfo::Rsa { + modulus: vec![3], + exponent: vec![3], + }), + ], + ..KeyInfo::default() + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.key_sources.key_value = false; + assert!( + inventory + .verification_resolver() + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::HmacSha256, + &policy, + crate::provider::default_provider(), + ) + .is_err() + ); + } + + #[test] + fn named_public_key_is_trusted_inventory_material() { + // A document KeyName must not inherit the source restrictions of the + // caller-owned public key's internal representation. + let mut inventory = KeyInventory::default(); + inventory + .add_public_pem( + "named".into(), + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"), + &ResourcePolicy::default(), + ) + .expect("public key imports"); + let info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("named".into())], + ..KeyInfo::default() + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.key_sources.key_value = false; + policy.key_sources.der_encoded_key_value = false; + policy.key_sources.x509_data = false; + assert!( + inventory + .verification_resolver() + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::RsaSha256, + &policy, + crate::provider::default_provider(), + ) + .expect("trusted inventory material resolves") + .is_some() + ); + } + + #[test] + fn named_public_key_obeys_current_verification_limits() { + // A permissive import policy cannot replace a later stricter operation snapshot. + let mut inventory = KeyInventory::default(); + inventory + .add_public_pem( + "named".into(), + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"), + &ResourcePolicy::default(), + ) + .expect("public key imports"); + let info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("named".into())], + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.resources.max_external_resource_bytes = 1; + assert!( + inventory + .verification_resolver() + .resolve_with_policy(Some(&info), SignatureAlgorithm::RsaSha256, &policy) + .is_err() + ); + let certificate = pem::parse(include_bytes!( + "../tests/fixtures/keys/rsa/rsa-2048-cert.pem" + )) + .expect("certificate PEM") + .into_contents(); + let mut certificate_inventory = KeyInventory::default(); + certificate_inventory + .add_public_der("named".into(), certificate, &ResourcePolicy::default()) + .expect("certificate imports as a named public key"); + assert!( + certificate_inventory + .verification_resolver() + .resolve_with_policy(Some(&info), SignatureAlgorithm::RsaSha256, &policy) + .is_err() + ); + } + + #[test] + fn unused_certificates_do_not_block_earlier_public_keys() { + // X.509 lookup bytes are irrelevant when a preceding DER key resolves. + let mut inventory = KeyInventory::default(); + let certificate = pem::parse(include_bytes!( + "../tests/fixtures/keys/rsa/rsa-2048-cert.pem" + )) + .expect("certificate PEM") + .into_contents(); + inventory + .add_certificate_der(certificate, false, &ResourcePolicy::default()) + .expect("certificate imports"); + let public = pem::parse(include_bytes!( + "../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem" + )) + .expect("public PEM") + .into_contents(); + let info = KeyInfo { + sources: vec![ + KeyInfoSource::DerEncodedKeyValue(public.clone()), + KeyInfoSource::X509Data(X509DataInfo { + subject_names: vec!["unused".into()], + ..X509DataInfo::default() + }), + ], + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.resources.max_external_resource_bytes = public.len(); + assert!( + inventory + .verification_resolver() + .resolve_with_policy(Some(&info), SignatureAlgorithm::RsaSha256, &policy) + .expect("earlier public key resolves") + .is_some() + ); + } + + #[test] + fn public_import_rejects_incompatible_usage() { + // Public material may verify or encrypt, but cannot authorize signing. + let resources = ResourcePolicy::default(); + let public = include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"); + let mut inventory = KeyInventory::default(); + assert!( + inventory + .add_public_pem_with_usages("invalid".into(), public, KeyUsages::SIGN, &resources,) + .is_err() + ); + assert_eq!(inventory.entry_count(), 0); + } + + #[test] + fn public_certificate_import_rejects_unsupported_key_family() { + // A syntactically valid certificate cannot advertise verification when + // none of the supported XMLDSig verifiers can consume its public key. + let pair = + rcgen::KeyPair::generate_for(&rcgen::PKCS_ED25519).expect("Ed25519 key generation"); + let params = rcgen::CertificateParams::new(vec!["example.test".into()]) + .expect("certificate parameters"); + let certificate = params.self_signed(&pair).expect("certificate generation"); + assert!( + KeyInventory::default() + .add_public_der( + "unsupported".into(), + certificate.der().to_vec(), + &ResourcePolicy::default() + ) + .is_err() + ); + } + + #[cfg(feature = "xmlenc")] + #[test] + fn imported_usage_restricts_operation_selection() { + // Explicit restrictions survive import and are enforced when selecting + // material for the opposite operation. + let resources = ResourcePolicy::default(); + let public = include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"); + let bundle = include_bytes!("../tests/fixtures/xmlenc/01-phaos-xmlenc-3/rsa-priv-key.p12"); + let mut inventory = KeyInventory::default(); + inventory + .add_public_pem_with_usages("verify".into(), public, KeyUsages::VERIFY, &resources) + .expect("verification-only public key imports"); + assert!( + inventory + .rsa_encryption_key("verify", &crate::policy::EncryptionPolicy::default()) + .is_err() + ); + inventory + .add_pkcs12_with_usages( + "decrypt".into(), + bundle, + "secret", + KeyUsages::DECRYPT, + &resources, + ) + .expect("decryption-only private key imports"); + assert!( + inventory + .signing_key( + "decrypt", + SignatureAlgorithm::RsaSha256, + &crate::policy::SigningPolicy::default(), + ) + .is_err() + ); + assert!( + inventory + .decryption_resolver("decrypt", &crate::policy::DecryptionPolicy::default()) + .is_ok() + ); + } + + #[cfg(feature = "xmlenc")] + #[test] + fn selected_rsa_recipient_obeys_operation_modulus_policy() { + // Import permission does not override a stricter encryption snapshot. + let mut inventory = KeyInventory::default(); + inventory + .add_public_pem( + "rsa".into(), + include_bytes!("../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"), + &ResourcePolicy::default(), + ) + .expect("RSA fixture imports"); + let mut policy = crate::policy::EncryptionPolicy::default(); + policy.rsa_keys.minimum_modulus_bits = 4096; + assert!(matches!( + inventory.rsa_encryption_key("rsa", &policy), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::KeySize { .. } + )) + )); + assert!(matches!( + inventory.public_keys()[0].rsa_encryption_key(&policy), + Err(KeyStoreError::Policy( + crate::policy::PolicyViolation::KeySize { .. } + )) + )); + } + + #[test] + fn inventory_merge_checks_names_and_budget_atomically() { + // Combining independently parsed stores cannot bypass name or + // aggregate-material limits, and a rejected merge is atomic. + let resources = ResourcePolicy::default(); + let mut first = KeyInventory::default(); + first + .add_symmetric( + "one".into(), + SymmetricKeyKind::Hmac, + b"first-secret".to_vec(), + KeyUsages::SIGN, + &resources, + ) + .expect("first key imports"); + let mut duplicate = KeyInventory::default(); + duplicate + .add_symmetric( + "one".into(), + SymmetricKeyKind::Hmac, + b"second-secret".to_vec(), + KeyUsages::VERIFY, + &resources, + ) + .expect("independent duplicate imports"); + assert!(matches!( + first.extend(duplicate, &resources), + Err(KeyStoreError::Selection("duplicate key name")) + )); + assert_eq!(first.entry_count(), 1); + let mut second = KeyInventory::default(); + second + .add_symmetric( + "two".into(), + SymmetricKeyKind::Hmac, + b"second-secret".to_vec(), + KeyUsages::VERIFY, + &resources, + ) + .expect("second key imports"); + let constrained = ResourcePolicy { + max_external_resource_total_bytes: b"first-secret".len(), + ..ResourcePolicy::default() + }; + assert!(matches!( + first.extend(second, &constrained), + Err(KeyStoreError::Selection( + "key material total exceeds resource limit" + )) + )); + assert_eq!(first.entry_count(), 1); + } + + #[test] + fn 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 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 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/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..afe9b87c 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -40,6 +40,8 @@ pub(crate) mod resource_name { pub const ENCRYPTION_RECIPIENTS: &str = "encryption recipients"; pub const ENCRYPTION_METADATA_BYTES: &str = "encryption metadata bytes"; pub const KEY_CANDIDATES: &str = "key candidates"; + pub const KEY_IMPORT_KDF_WORK: &str = "key import KDF work"; + pub const KEY_IMPORT_KDF_MEMORY: &str = "key import KDF memory bytes"; pub const KEY_INFO_REFERENCE_DEPTH: &str = "KeyInfoReference depth"; pub const BASE64_TRANSFORM_INPUT_BYTES: &str = "Base64 transform input bytes"; pub const BASE64_TRANSFORM_OUTPUT_BYTES: &str = "Base64 transform output bytes"; @@ -93,6 +95,20 @@ pub enum PolicyViolation { /// Observed consumption. actual: usize, }, + /// An import exceeded a resource ceiling, but its parser did not expose the measured value. + #[error("{resource} exceeds policy maximum {maximum}")] + ResourceLimitExceeded { + /// Resource whose consumption was rejected. + resource: &'static str, + /// Effective policy ceiling. + maximum: usize, + }, + /// A protected import supplied zero or too many KDF iterations; the parser did not expose the count. + #[error("key import KDF iterations must be between 1 and {maximum}")] + KdfIterationsOutsideLimit { + /// Effective iteration ceiling. + maximum: usize, + }, /// A configured resource limit violates a structural policy requirement. #[error("{resource} has invalid policy limit {actual}: {requirement}")] InvalidResourceLimit { @@ -412,6 +428,10 @@ pub struct ResourcePolicy { /// Maximum key-source expansion work and concrete key or certificate /// candidates inspected by one operation stage. pub max_key_candidates: usize, + /// Maximum PBKDF2 iterations or conservative scrypt work during key import. + pub max_key_import_kdf_work: usize, + /// Maximum estimated scrypt memory bytes during key import. + pub max_key_import_kdf_memory_bytes: usize, /// Maximum nested `KeyInfoReference` dereference depth. pub max_key_info_reference_depth: usize, /// Maximum bytes accepted by Base64 transforms before decoding. @@ -469,6 +489,8 @@ impl Default for ResourcePolicy { max_encryption_recipients: crate::hard_limits::ENCRYPTION_RECIPIENT_CEILING, max_encryption_metadata_bytes: crate::hard_limits::ENCRYPTION_METADATA_BYTE_CEILING, max_key_candidates: crate::hard_limits::KEY_CANDIDATE_CEILING, + max_key_import_kdf_work: crate::hard_limits::KEY_IMPORT_KDF_WORK_CEILING as usize, + max_key_import_kdf_memory_bytes: crate::hard_limits::KEY_IMPORT_KDF_MEMORY_CEILING, max_key_info_reference_depth: crate::hard_limits::KEY_INFO_REFERENCE_DEPTH_CEILING, max_base64_transform_input_bytes: crate::hard_limits::BASE64_TRANSFORM_INPUT_BYTE_CEILING, @@ -578,6 +600,16 @@ impl ResourcePolicy { self.max_key_candidates, crate::hard_limits::KEY_CANDIDATE_CEILING, ), + ( + resource_name::KEY_IMPORT_KDF_WORK, + self.max_key_import_kdf_work, + crate::hard_limits::KEY_IMPORT_KDF_WORK_CEILING as usize, + ), + ( + resource_name::KEY_IMPORT_KDF_MEMORY, + self.max_key_import_kdf_memory_bytes, + crate::hard_limits::KEY_IMPORT_KDF_MEMORY_CEILING, + ), ( resource_name::KEY_INFO_REFERENCE_DEPTH, self.max_key_info_reference_depth, @@ -1439,6 +1471,8 @@ mod tests { max_encryption_recipients: 0, max_encryption_metadata_bytes: 0, max_key_candidates: 0, + max_key_import_kdf_work: 0, + max_key_import_kdf_memory_bytes: 0, max_key_info_reference_depth: 0, max_base64_transform_input_bytes: 0, max_base64_transform_output_bytes: 0, diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 227a88d2..5c1bc664 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -3,6 +3,7 @@ use std::{collections::HashMap, fmt, time::SystemTime}; use crypto_bigint::BoxedUint; +use der::Decode as _; use dsa::pkcs8::{DecodePublicKey as DsaDecodePublicKey, EncodePublicKey as DsaEncodePublicKey}; use hmac::{KeyInit, Mac}; use x509_parser::{ @@ -29,7 +30,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 +445,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 +457,53 @@ pub struct DefaultKeyResolver { config: KeyResolverConfig, } +#[derive(Clone, Copy)] +pub(crate) enum ResolutionScope { + Document, + Trusted, + DocumentPrefix(usize), + TrustedPrefix(usize), +} + /// Counts candidates actually inspected by one resolver invocation. /// /// Parser cardinality preflights prevent expensive materialization, but do not /// replace this runtime accounting: embedded and indirect candidates both /// consume resolver work when inspected. -struct InspectedKeyCandidateBudget { +#[derive(Clone, Copy)] +pub(crate) struct InspectedKeyCandidateBudget { maximum: usize, attempted: usize, } impl InspectedKeyCandidateBudget { - fn new(maximum: usize) -> Self { + pub(crate) fn new(maximum: usize) -> Self { Self { maximum, attempted: 0, } } - fn charge(&mut self) -> Result<(), DsigError> { + pub(crate) fn charge(&mut self) -> Result<(), DsigError> { self.charge_many(1) } - fn charge_many(&mut self, count: usize) -> Result<(), DsigError> { - self.attempted = self.attempted.saturating_add(count); - if self.attempted > self.maximum { + pub(crate) fn charge_many(&mut self, count: usize) -> Result<(), DsigError> { + debug_assert!(self.attempted <= self.maximum); + if count > self.maximum - self.attempted { return Err(crate::policy::PolicyViolation::ResourceLimit { resource: crate::policy::resource_name::KEY_CANDIDATES, maximum: self.maximum, - actual: self.attempted, + actual: self.attempted.saturating_add(count), } .into()); } + self.attempted += count; Ok(()) } } -fn validate_key_info_source_permissions( +pub(crate) fn validate_key_info_source_permissions( key_info: &KeyInfo, allowed: crate::policy::KeySourcePolicy, ) -> Result<(), crate::policy::PolicyViolation> { @@ -526,6 +539,60 @@ fn validate_key_info_source_permissions( } impl DefaultKeyResolver { + pub(crate) fn resolve_with_candidate_budget( + &self, + key_info: Option<&KeyInfo>, + algorithm: SignatureAlgorithm, + policy: &crate::policy::VerificationPolicy, + provider: &dyn crate::provider::CryptoProvider, + candidate_budget: &mut InspectedKeyCandidateBudget, + ) -> Result>, DsigError> { + self.resolve_with_trust( + key_info, + algorithm, + policy, + provider, + candidate_budget, + ResolutionScope::Document, + ) + } + + pub(crate) fn resolve_trusted_material_with_candidate_budget( + &self, + key_info: &KeyInfo, + algorithm: SignatureAlgorithm, + policy: &crate::policy::VerificationPolicy, + provider: &dyn crate::provider::CryptoProvider, + candidate_budget: &mut InspectedKeyCandidateBudget, + ) -> Result>, DsigError> { + self.resolve_with_trust( + Some(key_info), + algorithm, + policy, + provider, + candidate_budget, + ResolutionScope::Trusted, + ) + } + + pub(crate) fn resolve_prefix_with_candidate_budget( + &self, + key_info: &KeyInfo, + algorithm: SignatureAlgorithm, + policy: &crate::policy::VerificationPolicy, + provider: &dyn crate::provider::CryptoProvider, + candidate_budget: &mut InspectedKeyCandidateBudget, + scope: ResolutionScope, + ) -> Result>, DsigError> { + self.resolve_with_trust( + Some(key_info), + algorithm, + policy, + provider, + candidate_budget, + scope, + ) + } /// Construct a resolver from explicit caller-owned key and certificate stores. #[must_use] pub fn new(config: KeyResolverConfig) -> Self { @@ -538,6 +605,49 @@ impl DefaultKeyResolver { &self.config } + fn check_configured_x509_material( + &self, + resources: &crate::policy::ResourcePolicy, + trust: &crate::policy::KeyTrustPolicy, + budget: &mut InspectedKeyCandidateBudget, + ) -> Result<(), DsigError> { + if trust.check_crls && trust.verify_x509_chains { + budget.charge_many(self.config.crls.len())?; + } + let certificates = self + .config + .trusted_certs + .iter() + .chain(&self.config.lookup_certs); + let crls = self + .config + .crls + .iter() + .filter(|_| trust.check_crls && trust.verify_x509_chains); + let mut total = 0_usize; + for material in certificates.chain(crls) { + if material.len() > resources.max_external_resource_bytes { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_bytes, + actual: material.len(), + } + .into()); + } + debug_assert!(total <= resources.max_external_resource_total_bytes); + if material.len() > resources.max_external_resource_total_bytes - total { + return Err(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + maximum: resources.max_external_resource_total_bytes, + actual: total.saturating_add(material.len()), + } + .into()); + } + total += material.len(); + } + Ok(()) + } + fn resolve_x509( &self, info: &X509DataInfo, @@ -599,7 +709,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 +1085,52 @@ impl DefaultKeyResolver { })) } - fn resolve_with_trust<'a>( - &'a self, + fn resolve_with_trust( + &self, key_info: Option<&KeyInfo>, algorithm: SignatureAlgorithm, - sources: crate::policy::KeySourcePolicy, - trust: &crate::policy::KeyTrustPolicy, - resources: &crate::policy::ResourcePolicy, + policy: &crate::policy::VerificationPolicy, provider: &dyn crate::provider::CryptoProvider, - ) -> Result>, DsigError> { + candidate_budget: &mut InspectedKeyCandidateBudget, + scope: ResolutionScope, + ) -> Result>, DsigError> { + let trust = &policy.key_trust; + let resources = &policy.resources; trust.validate()?; resources.validate()?; let Some(key_info) = key_info else { return Ok(None); }; - validate_key_info_source_permissions(key_info, sources)?; - let mut candidate_budget = InspectedKeyCandidateBudget::new(resources.max_key_candidates); + let document_sources = matches!( + scope, + ResolutionScope::Document | ResolutionScope::DocumentPrefix(_) + ); + if document_sources { + validate_key_info_source_permissions(key_info, policy.key_sources)?; + } + let source_end = match scope { + ResolutionScope::DocumentPrefix(end) | ResolutionScope::TrustedPrefix(end) => end, + _ => key_info.sources.len(), + }; let mut deferred_key_value_error = None; - for source in &key_info.sources { + let mut configured_material_checked = false; + for source in &key_info.sources[..source_end] { + if !document_sources && matches!(source, KeyInfoSource::KeyName(_)) { + continue; + } let resolved = match source { KeyInfoSource::X509Data(info) => { - self.resolve_x509(info, algorithm, trust, provider, &mut candidate_budget)? + if !configured_material_checked + && (if info.certificate_chain.is_empty() { + x509_data_has_lookup_identifiers(info) + } else { + trust.verify_x509_chains + }) + { + self.check_configured_x509_material(resources, trust, candidate_budget)?; + configured_material_checked = true; + } + self.resolve_x509(info, algorithm, trust, provider, candidate_budget)? } KeyInfoSource::DerEncodedKeyValue(public_key_bytes) => { candidate_budget.charge()?; @@ -1058,13 +1198,15 @@ impl KeyResolver for DefaultKeyResolver { algorithm: SignatureAlgorithm, ) -> Result>, DsigError> { let policy = crate::policy::VerificationPolicy::default(); + let mut candidate_budget = + InspectedKeyCandidateBudget::new(policy.resources.max_key_candidates); self.resolve_with_trust( key_info, algorithm, - policy.key_sources, - &policy.key_trust, - &policy.resources, + &policy, crate::provider::default_provider(), + &mut candidate_budget, + ResolutionScope::Document, ) } @@ -1089,13 +1231,14 @@ impl KeyResolver for DefaultKeyResolver { policy: &crate::policy::VerificationPolicy, provider: &dyn crate::provider::CryptoProvider, ) -> Result>, DsigError> { - self.resolve_with_trust( + let mut candidate_budget = + InspectedKeyCandidateBudget::new(policy.resources.max_key_candidates); + self.resolve_with_candidate_budget( key_info, algorithm, - policy.key_sources, - &policy.key_trust, - &policy.resources, + policy, provider, + &mut candidate_budget, ) } @@ -1151,6 +1294,7 @@ fn rsa_key_value_to_spki_der( modulus: &[u8], exponent: &[u8], ) -> Result, KeyResolutionError> { + let (modulus, exponent) = bounded_rsa_public_components(modulus, exponent)?; let key = rsa::RsaPublicKey::new( BoxedUint::from_be_slice_vartime(modulus), BoxedUint::from_be_slice_vartime(exponent), @@ -1161,6 +1305,30 @@ fn rsa_key_value_to_spki_der( .map(|der| der.as_bytes().to_vec()) } +pub(crate) fn bounded_rsa_public_components<'a>( + modulus: &'a [u8], + exponent: &'a [u8], +) -> Result<(&'a [u8], &'a [u8]), KeyResolutionError> { + let modulus = &modulus[modulus + .iter() + .position(|byte| *byte != 0) + .unwrap_or(modulus.len())..]; + let exponent = &exponent[exponent + .iter() + .position(|byte| *byte != 0) + .unwrap_or(exponent.len())..]; + let maximum = crate::hard_limits::RSA_MODULUS_BIT_CEILING; + if modulus.is_empty() + || exponent.is_empty() + || modulus.len() > maximum.div_ceil(8) + || exponent.len() > maximum.div_ceil(8) + || (modulus.len() * 8 - modulus[0].leading_zeros() as usize) > maximum + { + return Err(KeyResolutionError::InvalidPublicKey); + } + Ok((modulus, exponent)) +} + fn dsa_key_value_to_spki_der( p: &[u8], q: &[u8], @@ -1258,12 +1426,115 @@ fn validate_spki_algorithm( Some(EC_P256_OID | EC_P384_OID | EC_P521_OID) ) => { + 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(_) => { + preflight_dsa_spki(public_key_bytes)?; + let _ = dsa::VerifyingKey::from_public_key_der(public_key_bytes) + .map_err(|_| KeyResolutionError::AlgorithmMismatch)?; + Ok(false) + } + PublicKey::EC(_) => { + 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), + } +} + +#[derive(der::Sequence)] +struct BorrowedDsaPublicParameters<'a> { + p: der::asn1::UintRef<'a>, + q: der::asn1::UintRef<'a>, + g: der::asn1::UintRef<'a>, +} + +fn preflight_dsa_spki(der: &[u8]) -> Result<(), KeyResolutionError> { + let spki = rsa::pkcs8::SubjectPublicKeyInfoRef::from_der(der) + .map_err(|_| KeyResolutionError::InvalidPublicKey)?; + let parameters = spki + .algorithm + .parameters + .as_ref() + .ok_or(KeyResolutionError::InvalidPublicKey)? + .decode_as::>() + .map_err(|_| KeyResolutionError::InvalidPublicKey)?; + let y = der::asn1::UintRef::from_der(spki.subject_public_key.raw_bytes()) + .map_err(|_| KeyResolutionError::InvalidPublicKey)?; + if [parameters.p, parameters.q, parameters.g, y] + .into_iter() + .any(|component| { + component.as_bytes().len() > crate::hard_limits::DSA_KEY_COMPONENT_BYTE_CEILING + }) + { + return Err(KeyResolutionError::InvalidPublicKey); + } + Ok(()) +} + +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; @@ -1278,6 +1549,44 @@ mod tests { use super::*; + #[test] + fn xml_rsa_components_are_bounded_before_bigint_decode() { + // KeyValue import must reject oversized decoded modulus before conversion. + let oversized = vec![1_u8; crate::hard_limits::RSA_MODULUS_BIT_CEILING.div_ceil(8) + 1]; + assert!(matches!( + rsa_key_value_to_spki_der(&oversized, &[1, 0, 1]), + Err(KeyResolutionError::InvalidPublicKey) + )); + } + + #[test] + fn oversized_dsa_spki_parameter_is_rejected_before_bigint_decode() { + // A bounded SPKI may still contain a parameter much larger than the + // non-configurable DSA component ceiling. + let oversized = vec![1_u8; crate::hard_limits::DSA_KEY_COMPONENT_BYTE_CEILING + 1]; + let one = [1_u8]; + let params = der::Encode::to_der(&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!( + preflight_dsa_spki(&encoded), + Err(KeyResolutionError::InvalidPublicKey) + )); + } + struct RejectSecondSha512Provider { sha512_calls: AtomicUsize, verification_calls: AtomicUsize, @@ -3329,6 +3638,134 @@ mod tests { )); } + #[test] + fn configured_crls_are_bounded_before_der_parsing() { + // Invalid DER must not be parsed when its size or count already violates policy. + let key_info = KeyInfo { + sources: vec![KeyInfoSource::X509Data(X509DataInfo { + subject_names: vec!["CN=selected".into()], + ..X509DataInfo::default() + })], + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.key_trust.check_crls = true; + policy.key_trust.verify_x509_chains = true; + policy.resources.max_external_resource_bytes = 4; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + crls: vec![vec![0; 5]], + ..KeyResolverConfig::default() + }); + let error = resolver + .resolve_with_policy(Some(&key_info), SignatureAlgorithm::RsaSha256, &policy) + .err() + .expect("oversized CRL must fail before DER parsing"); + assert!( + matches!( + error, + DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, + .. + }) + ), + "{error:?}" + ); + + policy.resources.max_external_resource_bytes = 4; + policy.resources.max_external_resource_total_bytes = 7; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + crls: vec![vec![0; 4], vec![0; 4]], + ..KeyResolverConfig::default() + }); + let error = resolver + .resolve_with_policy(Some(&key_info), SignatureAlgorithm::RsaSha256, &policy) + .err() + .expect("aggregate CRL bytes must fail before DER parsing"); + assert!(matches!( + error, + DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + .. + }) + )); + + policy.resources.max_external_resource_total_bytes = 8; + policy.resources.max_key_candidates = 1; + let error = resolver + .resolve_with_policy(Some(&key_info), SignatureAlgorithm::RsaSha256, &policy) + .err() + .expect("CRL candidate count must fail before DER parsing"); + assert!(matches!( + error, + DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::KEY_CANDIDATES, + .. + }) + )); + } + + #[test] + fn configured_certificates_and_crls_share_external_byte_budget() { + // A stricter operation policy must account for all resolver-owned + // material on a selector path, even when the resolver was built under + // a broader policy. + let key_info = KeyInfo { + sources: vec![KeyInfoSource::X509Data(X509DataInfo { + subject_names: vec!["CN=selected".into()], + ..X509DataInfo::default() + })], + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.key_trust.check_crls = true; + policy.key_trust.verify_x509_chains = true; + policy.resources.max_external_resource_bytes = 8; + policy.resources.max_external_resource_total_bytes = 12; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + trusted_certs: vec![vec![0; 8]], + crls: vec![vec![0; 8]], + ..KeyResolverConfig::default() + }); + let error = resolver + .resolve_with_policy(Some(&key_info), SignatureAlgorithm::RsaSha256, &policy) + .err() + .expect("combined external material exceeds the operation limit"); + assert!(matches!( + error, + DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + .. + }) + )); + } + + #[test] + fn direct_certificate_does_not_charge_unused_configured_store() { + // A direct embedded certificate bypasses configured lookup material + // when chain verification is disabled. + let certificate = certificate_der(RSA_4096_CERTIFICATE); + let key_info = KeyInfo { + sources: vec![KeyInfoSource::X509Data(X509DataInfo { + certificates: vec![certificate], + certificate_chain: vec![0], + subject_names: vec!["CN=unused-selector".into()], + ..X509DataInfo::default() + })], + }; + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![vec![0; 4096]], + trusted_certs: vec![vec![0; 4096]], + ..KeyResolverConfig::default() + }); + let mut policy = crate::policy::VerificationPolicy::default(); + policy.resources.max_external_resource_bytes = 1024; + policy.resources.max_external_resource_total_bytes = 1024; + assert!( + resolver + .resolve_with_policy(Some(&key_info), SignatureAlgorithm::RsaSha256, &policy) + .expect("unused configured certificates are not charged") + .is_some() + ); + } + #[test] fn operation_policy_bounds_embedded_x509_certificate_candidates() { // Embedded X509Data is also composite key material. Its certificate diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index 86c5e7df..e094f953 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -404,16 +404,8 @@ fn expected_signature_output_len( .dsa_component_len() .expect("DSA algorithm matched above"); if component_len != required_component_len { - return Err(crate::policy::PolicyViolation::InvalidKeyMaterial { - operation: "signing", - key_type: "DSA", - reason: match algorithm { - SignatureAlgorithm::DsaSha1 => "DSA-SHA1 requires a 160-bit q parameter", - SignatureAlgorithm::DsaSha256 => { - "DSA-SHA256 requires a 256-bit q parameter" - } - _ => unreachable!("DSA algorithm matched above"), - }, + return Err(SigningKeyError::UnsupportedAlgorithm { + uri: algorithm.uri().to_owned(), } .into()); } @@ -3552,6 +3544,45 @@ mod error_conversion_tests { struct FixedRsaSigningKey; + struct WrongWidthDsaSigningKey; + + impl SigningKey for WrongWidthDsaSigningKey { + fn sign( + &self, + _algorithm: SignatureAlgorithm, + _canonical_signed_info: &[u8], + ) -> Result, SigningKeyError> { + unreachable!("preflight must reject this candidate") + } + + fn public_key_info(&self) -> Result { + Ok(SigningPublicKeyInfo::Dsa { + spki_der: Vec::new(), + p: Vec::new(), + q: Vec::new(), + g: Vec::new(), + y: Vec::new(), + modulus_bits: 2048, + component_len: 20, + }) + } + } + + #[test] + fn dsa_q_width_mismatch_is_candidate_incompatibility() { + // Lax search may skip an incompatible key but must not skip policy failures. + let error = validate_signing_key( + &WrongWidthDsaSigningKey, + SignatureAlgorithm::DsaSha256, + &crate::policy::SigningPolicy::default(), + ) + .expect_err("SHA-256 requires a 256-bit q"); + assert!(matches!( + error, + SigningError::Key(SigningKeyError::UnsupportedAlgorithm { .. }) + )); + } + impl SigningKey for FixedRsaSigningKey { fn sign( &self, diff --git a/src/xmldsig/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/tests/donor_interop_suite.rs b/tests/donor_interop_suite.rs index f5f29eb8..2cd5c7c2 100644 --- a/tests/donor_interop_suite.rs +++ b/tests/donor_interop_suite.rs @@ -1060,12 +1060,8 @@ fn dsa_sha1_rejects_a_key_with_a_256_bit_q() { assert!(matches!( validate_signing_key(&key, SignatureAlgorithm::DsaSha1, &policy), - Err(xml_sec::xmldsig::SigningError::Policy( - PolicyViolation::InvalidKeyMaterial { - operation: "signing", - key_type: "DSA", - reason: "DSA-SHA1 requires a 160-bit q parameter", - } + Err(xml_sec::xmldsig::SigningError::Key( + xml_sec::xmldsig::SigningKeyError::UnsupportedAlgorithm { .. } )) )); assert!(matches!( diff --git a/tests/fixtures/keys/pkcs12/ec-key.p12.b64 b/tests/fixtures/keys/pkcs12/ec-key.p12.b64 new file mode 100644 index 00000000..472d037c --- /dev/null +++ b/tests/fixtures/keys/pkcs12/ec-key.p12.b64 @@ -0,0 +1 @@ +MIIG9AIBAzCCBqIGCSqGSIb3DQEHAaCCBpMEggaPMIIGizCCBToGCSqGSIb3DQEHBqCCBSswggUnAgEAMIIFIAYJKoZIhvcNAQcBMF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBDF8jFgy5BFSkZmfCc9oOZAAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQMNsCiqbHJDBc+puNj8UO/4CCBLBQYh3ukIuJSb7/qGOG7r3pWyl4nF3562UnXWWyJo3Okm1/FGEVIxneSRLkEet5WGylojpc2IAmUhfVEyufWj2B5vPvxIZVOupf5baLCwjzVA2404aDvejBCgsrNJEpdwENi6ep00gRdBKjTogSpHiRFeu0t90zP6yybK41m+bi/U05NkHPYsIGGK6nGSJc7MxEFD7ynXPd7ebOXT6VrvqYgI97MUnplnOxAonBGWDUE6vTF5y7YXvGRM+s/zqrSWE/bwR37NjjlLhYI3ZKe4B5uxBKF400XBOsTbq+56B+CtomnHSxy5mo9GwNt9MR9zp0uH7kw0mSv/IETySUjs6RgyvmPx8izyxijGG4sCW4GBbZm8MW2cPTwo2oQ/LxBm6qP3AHveR6TBpmKRCtJ0fSIttLh/xN1taskzTuxoL+GHL45DhKUqupxPhYufPOKdLGiHAQAMV9wCBfHrSplC+KIXFG1m7Duit5sVt2GaHBZ6CsroiYsCRAca7RUL7oZvv8folwgz7EmMr1X0kJTOy+g7KnQg4IX58MTj6TwZZTO06ADp44leqbZ905V2HN8uDKKHDep/qXiMTbRztEn2YJcjEcLuSxpgTSDKgFWqtFRb84X9Am4Q/YAa7rPtyF+w2YvtA0TzBc+7Oyfrh7cZV6e8Yb5YwTxdwOPxZ+g/9V+gQtU3759o8QMjFtFhlVnfPnw6hVRYYzUk8dWcp0bx89GgjvAcPBmsICLfzc2/gc8F1aN95kUhVQNfmAa8SykxxMcKsre2C4CVzrhu8cBNOYQtecOH/WKpg9b+yYdUxb+Lyn9t8mumLkThIF+sTz65XnTdLON5jh1rq+Nnz9cFXhIiPaaC32REhXUAFDI4FbJ47hdqWBjmivncCHTWr82k9YWcxxB4d7iAoiczJOBxD7kQLb0DRGzhHAi1trsYcAL+zwH4cWGXPKCCPErHBUKPDSPywBsHC0pbQctIgEx2sEfCxHnkEhWMLV8/WhLDXybXnPzieLGMW/0ic3BNXb8K3+pFID1UQkFAsMIWCuRBw7oo/Oz8FhyfEHMIIxoouwsQdvDwd4b4cCs5nN3KK4cvGa7lray15WTEBuWkHtGkDO5n7k79AE+g/J+rL3Es48zlVjDqtJzZOrfn7RwtdELBaTtGJxz3/np8cvMr3hqs6/WT71WxgNpWdZnjcuW+W7Dq+Nvo0xMZ3Q7Zit0End+UoKZ7Pkt1ptU7ByMevzpmjcgA0chTUVx+8uT2HPIwj/cl8mCfYoyoxKHNoiSRMu2vl0Q74NpoZJTWdzMt/tkyw/Hd3AKBt9fPPbOkAmphFmGiKGjJdSTbDrkjMsi8ySEFtF+i4eT973xnBgb07LyCe5uZ9AXdKhvoFp+XHMUJuNFy9uBAZoN+AaCKFnzcSDXaxcHFbiCP6gbdzGMrG8wHm6vHJ7GQgfcyO6Z4teku6F6qi8hYTE+cPhMFHzHnSCXzLs4ynpDpyD46GbC/bHIhX+KKHtLsMzUR64AygYmZ6AS0pfv1fCh8ZSX02MgmD1zRLKu82twQAsQpyBAKunTPMjpDxSmjulqvqM5hytfw0MOLmDeMTeFNiHOghaI3K3lyQwggFJBgkqhkiG9w0BBwGgggE6BIIBNjCCATIwggEuBgsqhkiG9w0BDAoBAqCB9zCB9DBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQmV2bAWuiNtONQA14Ges4qQICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEBbOBNRA9zT7rLnUxeI0S2QEgZACTyB1VnU40cExSi8LiUSA8rbOFV535K8POpblEKm1DibOL3+4kiGPl7y4OF+h8FXu2QaYNMBq3tS8iy0hTNuUX+fgdoBtOcu/e+M1aTTKAZot6jkSD9me9Jzh4DB2V/qljLYinLmw2+CVOBdJ5lfPptDqLzDjU+a3Y1X2XOgHPhqBxWJGi0P2b6eMl+VAC6MxJTAjBgkqhkiG9w0BCRUxFgQUVACtd4hYFvB8+PJY2wGb4ncfW8kwSTAxMA0GCWCGSAFlAwQCAQUABCCsR2MBWsvUXExk13oX98r/dNVk73FHP8L67yIU2F1HPwQQgem2ZJRcJZl2sVa6fs+SSQICCAA= diff --git a/tests/fixtures/keys/pkcs12/rsa-duplicate-leaf.p12.b64 b/tests/fixtures/keys/pkcs12/rsa-duplicate-leaf.p12.b64 new file mode 100644 index 00000000..c95f426d --- /dev/null +++ b/tests/fixtures/keys/pkcs12/rsa-duplicate-leaf.p12.b64 @@ -0,0 +1 @@ +MIIRRwIBAzCCEPUGCSqGSIb3DQEHAaCCEOYEghDiMIIQ3jCCC0oGCSqGSIb3DQEHBqCCCzswggs3AgEAMIILMAYJKoZIhvcNAQcBMF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBDp80yMBPDVAoNEW2A4/JNGAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQocmW/MTbN7AclJ9pBPZTtoCCCsDxfu93HYYqx+ZzE3wtLecx6GF1jQ36UCRMrSxfzXbth14mkLL5lEsPz4XrlTu60wfw8Vd8M5G//8cHAMDpHh+0R1eZC6K1+dog8vew4oYmCaKRVODQqWqY7/lUbGAFZ+LSsJQ3ZdAAte1oQb3xKsLlwISQs5mwyM2fSfWaSowI+6fzimm59zCISzpS311JNOh4+0Ad+TlQbsAApfZl2jy+XemU4bKy52Eo92aW0U9aex1+66mV7p2eBhBLbimwyqkg+BXwwMmcFN2zWLS2opIAL0qsIjUkHx/7rjzopwM64+dcQucY9JZSliLmaOmywqkV5L7RQZdb0zs1CkqRKscgt++Kgf3a/kk2iqli3IAq/2IyVmt18gFsQno1u0x56InqA/x6yE5D6PsPJjXwrpvLu9Vp6LSwJDK2lYWc93s53aJhgRbhTCYH2Dwl5WpwQTzf7P0odNQ8SX5IvKW4EImXJwuoV2+BO4LzW9MDg3aLTJ3WkEGFkXDNUFlGQPNHlnSp7L9qDMZqDb9bqYqjn22KFPZUt4IqnIysnbPSyK4NQUkRMTmaMQuGwlL9cuwuB27pCnmZSUwefXBA2Qbj7osXymlwmT+u8F4BJqErgzxZKWnJ1AagmOQgWIm0kfL9sXjmTEGZei55Gk1sT6jYYth44hKorlbDFbZ2NIjOJVjZfDsHEumKE7FDUo/3YuAK5kF4IEICco0HO1H9bZ3zK/1SnmTiIr5Q/eY4untQHc7MY6zImyAO5SLwkdPJydCTyHhyP79oX3tIQ4X8Hf8EVOMudlUvRfwBcXrsVd/Wa4OXkAXIUrcp9021OVJhdMox/dOEk/VZTLFM1fp2/3TGYqDfUkyfkK/loG+Hh/3li13mlIydvMb04Ef65Cb3xEr7Myden1MjGXikh2avLh4rTsSm+cQAkdkXVjmPUuUyQNm6m1dDxQ/N1QjvEzDEo+1IhPTmlar3SkyNHFG36zdLBIW+TKTeWKCpkd8U1+fIFJAv/ZBHDiYZvOC5ol2Q3FBpYkf+80Xg6g50kXUHWxa9J32CqMcM4AaWhE5SfYXF16g9vzrx1VbK7JGPf2gsHlO8T3wrA9Ztf7YdHyGSL2xLLpjrB8vQzdR1Dop2qDO5tGSVC91BLmnJtmxdoXzlb0aZgsQJ0Dl7V4r9P935W/TyapMW9Ku/bit7JJG2hIGFyMsglu/QVzAp2KD16wBa+13SV/aY/4PrMie29Ks8IExWCQaDVtQL+liJvy2ioQgLmnzdZHG66TpVd910tS7dHIohUj0+rIti2OYXHWRe+oOBu1se154iiVxJMUOqcbJxMPMUN9zQ1xNYVZ2zgiL+X8xxKHxKJcZV/ZRbDlkUWlxBbkyqiM7MP/qM/vPWW+HoGi8e86UCMRY6zmpNoLp2rD20xrZtj5JSIYMXYNwV/j9lHmbYoHu5tnxuZo7V8XF+4knzmuXCMUABgSMPI6a61mij95myovF498cQTgPvfZBL3/F+qCTrEvM/oeB+ORsEwkSXH6J8edo80tRUi0BQ0+Hp4J3HzW4soN2BbsgOynWp3EtpmxCl8jYixH2idMrb/GqrNcp3udsuxwDGRvKdFzVSln9t/sbzlw0tB/kD8anQjQ1i5D2+4Tq4enn30C7PS2tMNpThxdROynKVDS6diNtzpjRBCMGBiPl2UZnrpAsiEpnFXPOQdQMxehlXGmy0dWp314+pQD7SeLOVWSzFlqBAnikkV9jLQApCBqPp0s04Cw8gMSwyPT9OSZRpsu0Vo38xbBT2LImxBJ2DGKWI/KS8Jx32BLrr2569c+XpjzEnEDLybGPHWNf7CZpGBadtPA6bXIn1hZoquomxW1Do3NbTsI5BwAzUxdgMVBzbMJ6Ob9IToZu0Wo7H9JHPyy6SArYgK8Fpdk1ZZ7vFmLUzUjS6s1Xr0asPjrLJKi8KCDJCDWV+IG+Ls2snsjew2k/PsaHbQn6I3VO5LZHBKmE1BGE9mDjx8GSnl9ITa3e21iD/6BHo9buRAhRZtqM46Qg8DDNuidvQOcR2X6vrW43LfVcsTMhPcGL7eTCf6pJjhIO8oirXmUcr2u6BZBh8P7HgwZHMQ0bcWuHUdf0q8IfFIq+MyfoMpTgl/HS18ks/SCyUrmb02F0x3fSSEZSNwfrwFRhppVd3aviBuF/492JUBHpbACq2XCKU+P+mzS6WKY05sAU44S2bz9Pw4SYdafXav5hn9YwklK/jbgtT7RFQi+dFwqiDNNcMKGYcYhTlR8JB2ObjqwiINtfSOVCEwcAcsBGKw3z6vO177rKdurheejqKTqZpX1WrFeSTMe33pE2wIq6MJfXCyVV8UVbzl0Gx2ZschYqsJB7jZ2tlqGg4cV13pVsvCRlMGWCBGAphsn6YMXvPFgoyk+W1J+e5EI4XmXAiKTZj71ccC6lJ5VjEQt5fB+S0Z5mm1YazY4oDnP2EIay8eeXge04OQeXWz8FAa1pcuc1/+0ckaTQlrH3CX83kjD2RaDGrmrwhP5jA5Eq6k+gfD2JYOvHHF8fQ+7gcfe+Uptr6pLXBzlMv5pPJM6pGWt+T/aFGW9lutkIEUxQk7uWjeHjdLmoC2l66XpN50boCxbogO992CuXkrpy6JVF2T9bZxR+pGOVYO4ryNXLHwYgiI8yqsMvpVBm+NwiK43gaFgl17twhfnYHhIIvuiqZy7vbDPx4yqDbD+1UeifwgZSA9LLjWQU2SajMXcYmwwA4LpR2l3uUczEZ6zMdokZ0/eoaEeXtJZrUPXbMA1zUiIcK+rpnd6Rou8Og/XjoGXJBLq7k7cQFz5DfSprIywWEoh3hGaGYmnVcadwp9DPyUwah06LaJt9yJtAeULmxJ7EkOKt7MzdTnXmKo5n2r7yiDmIvIPzmsGw9D6ND/L2hhUh1pWltipM8HQIhgam1KuMCZKD/EUIq6C2Jj4w0P1aEM/nODPfSaYSMgNjOsTqSZGHoi9oJtDS1KoJamfNIAOFnR+pRrxWKgMnCSdgbi6rAuSBd7oDwbv2nCxnvX2ENOynIZYLcyELzOpIv5wo7DJIx72Ukai0CI1IwbC+bmRaQRmE+UdBPgftdDZdUmT1zA/rcWoGES6A4JTugfhXhqlmDxTja3lz23m0F67quy/ejFmFYZKgCUob8ZjebCk03DoMoR44cy5//Hw+jF/8zjN4Ee3FLNoX3uuRz6kh4qPmavhm/z++fR5/fOpvsJ2k8bOyMiSSiWlzwBteURSCsSD6n02NW8CaLGWZRyk4x4f+ZvGHp3gpA7CWiqVCP8qYkMgOssza8J2mWG8/R2XR/PLvFxGOXvz/fskzk+WkunzypVvuFhjg9q+iaNZJjsLXQHlZxDhYIdnDW9Une7nadforPLescQulWQVUVUwRIzH0xFAwb/zZsbZEQLnFMROgGNJPca+4pN1BO7eufjNmzTuIZnZUnM+NndC7UQfOZbagqtKQjOVLuCA04NisUfv0Xj5WtzkgsVLWxkrPLuHWa6oAyBbcvWTpTmESWJQkPfaUW9h8gXsjsD/yxM88DYfsu1gh0pbRNIQwtD83yrEpnazNeaXxCPafUheFE8fAxM3dJL3VJ6muTN3MuOu0giedIv8lnmGaqMtljdQ9A2xjqwLiDnPgJgpgJh2PkEBcQEjETqovxd11y1NYvL1cz62dv9JYBMIIFjAYJKoZIhvcNAQcBoIIFfQSCBXkwggV1MIIFcQYLKoZIhvcNAQwKAQKgggU5MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQwN6CcCxHuiU+/RH+lhWTFQICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEC/CMWOiz5ZG48NYsc3YXogEggTQYnFNjwK9/dPVgmdplmdNjnJg56oM/fD2/KU52i8aE6pk838ZU2pi1kpp5QmOYyyISnPiZXYUNUNFtPn3AkfuA+4jF9XQxrNse/dXAWdPxbauvL2aUyQy8DgQ7OjhEsRwCS/ky6UkKc4yFg/RSSgI3i0kMO/w3eK2YJJSKvCo8ajD+uo1KCz0+oM6ES+PWEN3H/uribO91mokEkhPOUvIXl37KK3bSK3jpyt7ohbT5uuCCR/p9HSoWTVUuZToVmqMU0wGHcjcULjO3nyoNAGUY+W28CHY8TCGGMrxuKtMXT5MKutF7TETM0p5REWgamXPVnTBYpbsp5ec8eZhuONF/DBxa6uaocgTL0OB/58CqN+CYXRHC2nWtURlJ0ZOPJPo2pD7PquG9KVlR13agKV5ZPVG9rehDbIcNtt6UA8MmBdNMeepaU1J1kDxAhMIXLt+EfH42DNwmGMb2QvKBg9Oz4nrCZX7P9O/nbzAKbxFopYLpPlYJNu9dR5mJ6/DtN+Jh/jaM/+USR7gqsdufSuD6+YcA1ku4lmciIamfCInG0XXpqvx9HBL/jHX9Us+SIQ/+Jj5jCpER4zDFDz5b+7xj4WM2F+uSMegZgLXcuv5m73+xBL/JIog30chnK6juYHw0lzRl9FC9HtzekeD6TSJgi4uJTCzGLXm9l3g0q+c+m3YPvsVInowU0rg41PuODXSx/1hHWEKOI/Z60joX2/iyBYw3YyMwkEGiugcBvXvDILAdUmF811RV7mqZ1B/pH6Mlq5X4NIJRO3SPg6f7inWPJ93ob8c54B3+Ayr9qeyF35MyTIhq0oUbzZ0IQWmuubHxvjiNB8wKsMlKnHVcr+g71/+bIXCRKQKffCZ4wwz9tLKJSz/Z5vAUjc6z5fYE26vo5r+z/2EsaKUHzfWsDeHOfYfqfhiOfebR88ycWGMClTFCY7e8Tnx7SJdBL5U5vqQO7IEqJP0ppjB4vxb409i4aSYeWEFfiP+QPqMwACgdgytxCWJTEq0bbj+EKPtiTKuPaYXAK2ixZz0igwVCBL9Zb4Gp6RmmmVn7DVWFKQKstFksdy/HrJ7nGoq37MB3kvxT7pR7kERo1Jw7bCqYM21C8zApaCSmmyqFPi3JT35iJmPub3JuEDdu3xNMqWZc2bzPGvrjHgEoYE+qLN5X7cAsOaT/Azj1bgP1lvsuKO0rfEcG1jInL6TPtIRI8nnHE8/kEt3rUXa6isKXKMipvrZeNBobTSCwLbkJwyuNMhQw59FJb+PxMjpxBfPU1wb01qjtHsp0jlQdV728AEVYlaoeXhlujEmWLH/slrLj2z6uD83MXbrZTNGNoUZ05/Buq8fAbk4RRRmdjkXD8zJEH6+TonALq5Tr2uaH7PJTtDsLkW4UwM0uoqCPSnTgs5ztEI70TDvm+qhnAs3R/aFHrP1eJ9PRXkTcvFRvYjoKzPpXrBorvBpxtiFN6KvGjp6ShZVNLIJRro3ARB2Te0ovjGRvbBIBOBRWF4kBIm2xatBU26Q0wv2bngiippJhnK52RTqfPwbUnwPR9gf8lxNo2/bB/2hiD0QPHgoXIwtfPdqadzx27o7qBqqQnoDZvaq8aTmyXn/n7bZqh0l9O9N1co9ehHATtAPkDedPWl9ibfpFWwxJTAjBgkqhkiG9w0BCRUxFgQU0SRn3soHyXpJTOMGSFEOp8rza/MwSTAxMA0GCWCGSAFlAwQCAQUABCAHt2NQhx5gjkUxcCsLGOv5qRr8usyOtRfe/AWYzNjR9QQQ0PTJogoUfMcxxVRqM1IHmwICCAA= diff --git a/tests/fixtures/keys/pkcs12/rsa-key-unrelated-ca.p12.b64 b/tests/fixtures/keys/pkcs12/rsa-key-unrelated-ca.p12.b64 new file mode 100644 index 00000000..feff37d3 --- /dev/null +++ b/tests/fixtures/keys/pkcs12/rsa-key-unrelated-ca.p12.b64 @@ -0,0 +1 @@ +MIIL4AIBAzCCC44GCSqGSIb3DQEHAaCCC38Eggt7MIILdzCCBgoGCSqGSIb3DQEHBqCCBfswggX3AgEAMIIF8AYJKoZIhvcNAQcBMF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBCqTaaLuBOCLGP6qov0S5G9AgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQxSluuDBJneV63R0w1JkOToCCBYAOoaoKj8oolrey3g9irnf4Po+anKW0pjym+LhXTZ3a1hW11+1ptVJ/Rr+C8BjBug5qW5D1+M7Rpt6S3WRYnBQ3ywOfhrYW5KW/b6KMrrOMTcnWUwY0exi7btr1zNAakEy/F6CPqc/MbRoZD0bFmbkO+bkDfuDjUKSL45jctfj/MjPsPfVGIcHqhmy0EwzOrqt9xfigvPrMdivmG5mOXSRJHrolTzAY0pPlqlWGC8/ksJDIuyejN1cID7LGvLZXNPSwSkiQxWi47poJXnqCWk4rEg/HuoF70EOa6nYFaDsdZCnM2JN1N1mqfwxKtN0tnI/hMKkbu45cGGNv9IEE1DKCOY27UEOA+wQkR3BjtjISwoEmNnnrf29qBp090QsR70UYR7Wku34xZT1vUDHkDiisFiDJG5mHtFfKXJmv66hEOPoMQ+r71qhAld6QfzG7eK2/J0iewA0VrtvP6bDu0pbmutZHFNkbdh7VL1xbjVyG31eYR2IyFTAO+1b/jSSWvLLgOICiIC6huUE8TelkI/qRrTvHqcY1WO09arVFnWaYJl2uRSVBuwEQuU4e/vQPuZHeZUlbglmj+YO83M3lsP+oQSDuMIKwM2XrsdAd6iF2ej+9ufFwmCs/xyToba3jWB+I5WdtpxXUOcOygjMNI+l9w/6HRmhxj1VZCub6IhqOhtlH26FKBCiLyla+CIhyWvXRgUI8oJAM8BZ/WkPgj/OTq9az0JwhTjjRRRefRViAvGTVqd6Bvx2BgNjFLENguiUWtF0UC8+nMQMRekDGTEqyHamqjLcHYljGskAzGVBI5beOlh7O5yPOJORZMY7t0ot0A2e3jqdfa+zwI8o4PyGib1VMhNv1meYrdNdz1ctrvrR+eWxX8IUxT1DP1q3oF3Fq9tSoYydbKcpfZ0oXIYiauQuwxc5nC31LtiwEUqKzgewWD8znJgGV6ixya5vHc95PtOGoRsggIj2NvFHuQzsZHmFwSTdBP1sI2w91oJG7XS4uiJBN6Ufbc7uMBgnzkWlcfCXak6FOR81UcGv6aYrNn1x2v4d91StyvpSgJjmLkAmm5Ae20cVJuwVXE7P5+GVULpockpelKQJV53V5Cx6UevCi4+eDqOZYl/TyTSHbCFgsXldj8ICvHOdtBwHxB2P9fSU58vl2zuUWNWrMU0mNBCAKz+7QFamzkvrMKEFSWzK4szXiFYyxpiBo3jhqLH/gCgL+GCe+DwfyCBHXeO2ieoxqoHCTBLXXbyjxu+hpzOCPoLwIj2VAIkr5PB1G8e1Ht0yYHNt2nKgk8M1OA2sLRn41IvRL9D3SheDISiisvdT2vWu26ReKomG/Yn/UqqiHr1iiLIJVN1xFkHg2GWQ2Ngz1ff9wDVAXsp0iMH3WiXc38ozo6ykb0yjsarRBYyf1IxnMhHfr9YtWjb8c/fWfrIEHMPZYf/CGLqttRbsENxZHtCGJMAlM6/A16SgXtkzxXGy+kEn0m+Zw2qA6OhDTFN27WJxlt2vSMsaBQGwBx3vxucUtOMHuthi3S05C7ErnBikC0Qy5wjQMoGng7bjWBPUS97+oLIFMAWDTHx7yLpX87tDFfd8V4eFKDx2y/POrHx+xBcCfflI4sZF98vYOeoRMPNdtTPOMW9REAPoKoZZaPhj/P454uYP7/akksFC7rYMNJH6pC6U3kLt67P62Z9a2SQPIqrfT8etfXCrxP2rZWUTst3myVLE5yKBJhZYUzcLiZMsCFhn+N8o0ZIo13TiDw8lqV6BcD0+aveLVIN3DXlLqs0lsN22HdmIfvevLb7xurlN+CwLDVqcRGNwGWGK8mLqmT/XxIqld3alCGST/Rg9ciFiCVajrEFbRH0JMUXhIqspgdIjesF8imOWEMIIFZQYJKoZIhvcNAQcBoIIFVgSCBVIwggVOMIIFSgYLKoZIhvcNAQwKAQKgggU5MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQ3y1iL21yckfUspImVh5rMwICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEEdACKY4qzn6EkAIZjzdxr8EggTQWos4Zqt9K0Q9eV1xdOT+1ycWlaA+p/3WksA5K7V/SHwjNnIX55nnix57UHNrHhnd2JRNW3GXunkEsgytAA1J36M7M7fwn0EDIiGEGNbdwvrLdOrBf/uIe5dAiB4nUZDi1PAUKJRiPolfL1MHL5BzV8Zwo5yikRHGdt/FloQh6o5emh6L0plkhfF8Bv4cFDET+ABo8mPy1wYIsCViyYXdsWimBuHxGmunH2uJ0EHbA3CwRFptscs3dRyShrBEahqsxzjrsi/PTeOsKkB4lEMhqdkAYpZ9M/dEFtYUD/H6PYz1dQkCTH7PzIkwKzllXLdEtwjvfMB/tEgbLCzshfE7UXHx71QRQr11mE4su36645p7uvBjXH+X5ucCvflwXEdBaTWL2Q7HwhuvVINgF1SqNsZA7YHcwO9oXaik0uZWLnGi3KmnqYzFVBZcZXbu1ldM4Wk7LqLfHo+GiD7d1sLktsTyLw5M/bv3Zo5QfmQU0g1wltdNz1D0jfvqMnNi6GFmu6NUgrj58SOwkuUYH5QFeBcn5+cWOhg1O7VNIZEfTJ1eKEWlozBKJyD/1uqRP04k43GDSRRozv9g89zMz56AbVuP8S1UxzrD2AVmdTJ4wFWbYpSrMbRXoFgv2b/F+Y8LFEmbG+CvwN4Kni8c0ZejWA6BysxrSK9pog4TcnUVrjPDSF5fYpJOJnx5/qjHYa5K5os9R8kHOogvZ1GYNGPAK9gDqpu3YMJzmFpZAIrc6i8un3Bc6m4DL7prYekKnDuVHZN+Q0K9FtHTsDcPZiXyLhlNmPfYf/ZFA0voIUFXfoEuI4lGztMigpcg0Yd01BpLrLyGztL48ud4h7Dk5IClWEuDhGmnLiP+DWXE9Sh9W48IQpCLsuoryERa/4cvmlrvfqdKv7H2xP+HS30YNeapB5XVlwuLkZepDIjym6QSKZSOZTYzddZOEnLmkryUlouiVpfiTf7FNBZ3otx2jpkhKpRhuv/pqHsvmFKvr8h9tfUP89AuAXooO4fI/YfAXmgl7Xc8RLfBbPLCpMufDEk3TsOQn836+hK6XJnaSQikb0HaUmyuZXaT2sHVNYdt9WlbPU85SVz4pWImVf+My1KD/TkHndubn0aRJDQsb/bi0sQx4Nzw5qTnfDjH7nBt7o7BodmkROWSyWUzE53S4H6jNoKRaVWVXyRFwiHmx/62nHS/VW/6fdXVvV2TLGu0yG5EEORJrC7poAhO5pxi/dMgM74k5Q4QnzHblZJTJ6m/I39LXF+gEwUTsmk7O3uK8yKMfbbhpv8bmT8wNQOIWECA/SvytpOdGLvf05Bhim/Ud4o7m0AzRhr3LPEFVp9A46r5jTr08C2sEkRM/fbuUXc0F5seR/EEiff7FW1mHa6KoK2W6tdvH3gGvfVaxMrtu/6aab1A5bw0WTLxq5D9ug6BflGFmkFqZCa/tsW81hxeDz+v3ZV9sO4cI41vcyPUigTEee2R336IDRUHTyQu2XYJ9MmMMLe9A13ZNGg2H43UmDJcOkvxLjxmovMpHgS8mhPSnUAKmD19ll95xhmIz04+xEGGbGVEXCR6RTzh7LXT2279i9xoXX7lN62ycb0bOFOgwmm/pxMIT53ZOueqeBOMSXbP6LzGZpI5U5cLGzf1Vv8wSTAxMA0GCWCGSAFlAwQCAQUABCDZFOldtGCTuJq9OVOZzkbk4oYRRYAgbDGG7g7ULprWTgQQn0bo65DpCVeQcOFKaQgeoAICCAA= diff --git a/tests/fixtures/keys/xmlsec/mixed-keys.xml b/tests/fixtures/keys/xmlsec/mixed-keys.xml new file mode 100644 index 00000000..0bda1d79 --- /dev/null +++ b/tests/fixtures/keys/xmlsec/mixed-keys.xml @@ -0,0 +1,52 @@ + + + + +test-hmac-sha1 +c2VjcmV0 + + +test-dsa + +

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

+9WQwByMPy0u1C8e2SeNQTvkG6tM= + +Rrg7e8pNLHMFK0pGW7xvzb7Kh6icJSsiBaX6aHqaQc9rSzzMJG3snBuQricNaUH5 +8ipucT+hdPRTo6g0ty5noyyBmqUvYHf9NuskQhPDmC3uTtqQTHeCEuX8XoH3YYlB +uE4nXvQRGZoyy+43ISe9aDnEAgIUVQXEayTVppRF24I= + +S3Gt9BE+wZb996U6h4nSNtYxEmE= + +WT0+1bR+bj65u5iDJ0MRc6/8iEAbvj7l5sAVn/H+SdZy94wW5mnSLCC5ufN33QPp +WNvgVk2igM+W51WlhFDgA8Xz9lRPk19jW8BXQpqv11MKoIBpaSAWvnhs/0AKubiT +XxJz7i78ZJy4hVTn99Rvt6Tc16/LICZfsqIJr+VK4Sg= + +
+
+ +test-rsa + + +0rGgazIyv0XjPXGGBwt1wvfCPO++VAlxW15LFinbxCeBkq/5jb/71gC7R2CJtUK4 +y/tIi7g89YBwQosJpgMMZt69fz51omEv/WobD0vUFcbRxek+Yi23ZHxhZMtO42Re +zfpwgC4ep0fXL+V105BUmjGFYACnUJdtMkG8ahH8/Zs= + +Aw== + + + +test-aes128 +0Xfy3ES+Fbv/OfWuQHKvPA== + + +test-camellia128 +0Xfy3ES+Fbv/OfWuQHKvPA== + +
diff --git a/tests/fixtures_smoke.rs b/tests/fixtures_smoke.rs index 21ba40ef..adc3e3f9 100644 --- a/tests/fixtures_smoke.rs +++ b/tests/fixtures_smoke.rs @@ -193,7 +193,7 @@ fn c14n11_xml_base_input_present() { #[test] fn fixture_file_count_matches_expected() { let expected = [ - ("keys", 28), + ("keys", 32), ("c14n", 41), ("xmldsig", 207), ("saml", 2), diff --git a/tests/key_manager_feature_contract.rs b/tests/key_manager_feature_contract.rs new file mode 100644 index 00000000..37791980 --- /dev/null +++ b/tests/key_manager_feature_contract.rs @@ -0,0 +1,11 @@ +#![cfg(feature = "xmlenc")] + +use xml_sec::key_manager::KeyInventory; + +#[test] +fn xmlenc_feature_exposes_key_inventory() { + // A consumer selecting the XML Encryption feature can compile the shared + // inventory API without separately naming the XMLDSig feature. + let inventory = KeyInventory::default(); + assert_eq!(inventory.entry_count(), 0); +} diff --git a/tests/xmlenc_encrypt_xmlsec1.rs b/tests/xmlenc_encrypt_xmlsec1.rs index c1bba2d2..65f31343 100644 --- a/tests/xmlenc_encrypt_xmlsec1.rs +++ b/tests/xmlenc_encrypt_xmlsec1.rs @@ -17,6 +17,7 @@ use xml_sec::xmlenc::{ DataEncryptionAlgorithm, EncryptedDataBuilder, EncryptionRecipient, OaepDigestAlgorithm, RsaOaepParameters, }; +use xml_sec::{key_manager::KeyInventory, policy::ResourcePolicy}; static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -152,3 +153,42 @@ fn xmlsec1_decrypts_rsa_oaep_wrapped_aes_cbc_from_xml_sec() { plaintext ); } + +#[test] +fn xmlsec1_decrypts_rsa_recipient_imported_by_key_inventory() { + // A caller-owned inventory, rather than a directly decoded RSA fixture, + // must preserve the independent libxmlsec1 transport wire contract. + if !xmlsec1::is_available() { + eprintln!("{}", xmlsec1::skip_reason()); + return; + } + let public_path = Path::new("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"); + let private_path = Path::new("tests/fixtures/keys/rsa/rsa-2048-key.pem"); + let public_pem = fs::read(public_path).expect("public-key fixture must load"); + let mut keys = KeyInventory::default(); + keys.add_public_pem( + "inventory-rsa".into(), + &public_pem, + &ResourcePolicy::default(), + ) + .expect("named public key must import"); + let public = keys + .rsa_encryption_key( + "inventory-rsa", + &xml_sec::policy::EncryptionPolicy::default(), + ) + .expect("imported RSA key must be usable for encryption"); + let plaintext = b"inventory-backed xmlsec1 interoperability"; + let encrypted = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .add_recipient(EncryptionRecipient::rsa_oaep(public).key_name("inventory-rsa")) + .encrypt_binary(plaintext) + .expect("inventory-backed encryption must succeed"); + assert_eq!( + decrypt_with_xmlsec1( + &encrypted.encrypted_data_xml, + "--privkey-pem:inventory-rsa", + private_path + ), + plaintext + ); +} diff --git a/tools/xmlsec1/src/args.rs b/tools/xmlsec1/src/args.rs index 2161b233..fafc0d86 100644 --- a/tools/xmlsec1/src/args.rs +++ b/tools/xmlsec1/src/args.rs @@ -210,6 +210,7 @@ pub(crate) const OPTION_SPECS: &[OptionSpec] = &[ option_spec!("privkey-der", [], VALUE, true, MULTIPLE), option_spec!("pkcs8-pem", ["privkey-p8-pem"], VALUE, true, MULTIPLE), option_spec!("pkcs8-der", ["privkey-p8-der"], VALUE, true, MULTIPLE), + option_spec!("pkcs12", [], VALUE, true, MULTIPLE), option_spec!("pubkey-pem", ["pubkey"], VALUE, true, MULTIPLE), option_spec!("pubkey-der", [], VALUE, true, MULTIPLE), option_spec!("pubkey-cert-pem", ["pubkey-cert"], VALUE, true, MULTIPLE), diff --git a/tools/xmlsec1/src/commands.rs b/tools/xmlsec1/src/commands.rs index 2d1b1d1c..815237cb 100644 --- a/tools/xmlsec1/src/commands.rs +++ b/tools/xmlsec1/src/commands.rs @@ -16,6 +16,7 @@ use x509_parser::prelude::FromDer as _; use xml_sec::xml_input as xml_sec_xml_input; use xml_sec::{ IdAttributeRegistration, XmlBackend, + key_manager::{self, KeyInventory, SymmetricKeyKind}, policy::{ DecryptionPolicy, EcdsaSignatureValueEncoding, EncryptionPolicy, HmacPolicy, ManifestProcessing, ResourcePolicy, SameDocumentIdSemantics, SigningPolicy, @@ -69,7 +70,9 @@ const SIGN_OPTIONS: &[&str] = &[ "privkey-der", "pkcs8-pem", "pkcs8-der", + "pkcs12", "hmac-key", + "keys-file", "pwd", "lax-key-search", "node-id", @@ -89,6 +92,7 @@ const VERIFY_OPTIONS: &[&str] = &[ "pubkey-cert-pem", "pubkey-cert-der", "hmac-key", + "keys-file", "trusted-pem", "trusted-der", "untrusted-pem", @@ -120,6 +124,7 @@ const ENCRYPT_OPTIONS: &[&str] = &[ "binary-data", "xml-data", "aes-key", + "keys-file", "pubkey-pem", "pubkey-der", "pubkey-cert-pem", @@ -137,10 +142,12 @@ const DECRYPT_OPTIONS: &[&str] = &[ "print-xml-debug", "output", "aes-key", + "keys-file", "privkey-pem", "privkey-der", "pkcs8-pem", "pkcs8-der", + "pkcs12", "pwd", "lax-key-search", "node-id", @@ -194,6 +201,8 @@ pub enum CommandError { ExternalMaterialTooLarge { maximum: usize }, #[error(transparent)] Key(#[from] key_material::KeyMaterialError), + #[error(transparent)] + KeyStore(#[from] key_manager::KeyStoreError), #[error("XML signature operation failed: {0}")] Signature(String), #[error("signature is invalid")] @@ -695,6 +704,68 @@ fn named_candidate_search<'a, T: Copy>( ) } +fn load_xml_key_stores( + invocation: &Invocation, + policy: &P, + backend: XmlBackend, + budget: &mut ExternalMaterialBudget, +) -> Result { + let resources = policy.resource_policy(); + let mut all = KeyInventory::default(); + for option in invocation.values("keys-file") { + let path = Path::new(option.value.as_deref().unwrap_or_default()); + let bytes = read_key_material_with_budget(path, budget)?; + let store = KeyInventory::from_xml_bytes(&bytes, policy, backend)?; + all.extend(store, resources)?; + } + Ok(all) +} + +fn select_store_candidates<'a, T>( + entries: impl Iterator, + requested_names: &[String], + lax: bool, + max_candidates: usize, + name: impl Fn(&T) -> &str, +) -> Result, CommandError> { + // Policy caps candidates per stage, not the sum of selection and crypto + // attempts. Bound this scan independently before materializing matches. + let mut named = Vec::new(); + let mut fallback = Vec::new(); + for (inspected, entry) in entries.enumerate() { + if inspected == max_candidates { + return Err(CommandError::KeyStore(key_manager::KeyStoreError::Policy( + xml_sec::policy::PolicyViolation::ResourceLimit { + resource: "key candidates", + maximum: max_candidates, + actual: inspected.saturating_add(1), + }, + ))); + } + if requested_names.is_empty() + || requested_names + .iter() + .any(|requested| requested == name(entry)) + { + named.push(entry); + } else if lax { + fallback.push(entry); + } + } + if !lax && named.len() > 1 { + return Err(CommandError::Usage( + "multiple matching keys in --keys-file".into(), + )); + } + if lax { + named.extend(fallback); + } + if named.is_empty() { + return Err(CommandError::Usage("no matching key in --keys-file".into())); + } + Ok(named) +} + fn sign(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), CommandError> { validate_options(invocation, SIGN_OPTIONS)?; let xml_backend = selected_xml_backend(invocation)?; @@ -713,14 +784,82 @@ fn sign(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), CommandEr &policy, xml_backend, )?; - let selected = select_signing_key( - invocation, - &signature.key_names, - signature.algorithm, - signature.key_info.as_ref(), - &policy, - password, - )?; + let has_key_store = invocation.values("keys-file").next().is_some(); + if has_key_store + && invocation + .ordered_values(&[ + "hmac-key", + "privkey-pem", + "privkey-der", + "pkcs8-pem", + "pkcs8-der", + "pkcs12", + ]) + .next() + .is_some() + { + return Err(CommandError::Usage( + "sign cannot combine --keys-file with explicit key options".into(), + )); + } + let selected = if has_key_store { + if signature.key_names.is_empty() && !invocation.flag("lax-key-search") { + return Err(CommandError::Usage( + "sign with --keys-file requires a template KeyName unless --lax-key-search is set" + .into(), + )); + } + let mut budget = + ExternalMaterialBudget::new(policy.resources.max_external_resource_total_bytes); + let store = load_xml_key_stores(invocation, &policy, xml_backend, &mut budget)?; + let lax_candidates = invocation.flag("lax-key-search"); + let candidates = if signature.algorithm.hmac_output_bits().is_some() { + select_store_candidates( + store.symmetric_keys().iter().filter(|entry| { + entry.kind == SymmetricKeyKind::Hmac + && entry.usages.allows(key_manager::KeyUsage::Sign) + }), + &signature.key_names, + lax_candidates, + policy.resources.max_key_candidates, + |entry| &entry.name, + )? + .into_iter() + .map(|entry| entry.name.as_str()) + .collect::>() + } else { + select_store_candidates( + store + .private_keys() + .iter() + .filter(|entry| entry.usages.allows(key_manager::KeyUsage::Sign)), + &signature.key_names, + lax_candidates, + policy.resources.max_key_candidates, + |entry| &entry.name, + )? + .into_iter() + .map(|entry| entry.name.as_str()) + .collect::>() + }; + select_store_signing_key( + &store, + candidates, + signature.algorithm, + signature.key_info.as_ref(), + &policy, + lax_candidates, + )? + } else { + select_signing_key( + invocation, + &signature.key_names, + signature.algorithm, + signature.key_info.as_ref(), + &policy, + password, + )? + }; let mut context = SignContext::new(selected.key.as_ref()) .policy(policy) .xml_backend(xml_backend) @@ -801,6 +940,40 @@ struct SigningKeyCandidate { leaf_certificate_der: Option>, } +fn select_store_signing_key<'a>( + store: &KeyInventory, + candidates: impl IntoIterator, + algorithm: SignatureAlgorithm, + key_info: Option<&KeyInfo>, + policy: &SigningPolicy, + lax: bool, +) -> Result { + let mut last_error = None; + let mut lookup_budget = key_manager::SigningLookupBudget::default(); + for name in candidates { + let attempt = store + .signing_key_with_budget(name, algorithm, policy, &mut lookup_budget) + .map_err(CommandError::from) + .and_then(|key| { + let candidate = SigningKeyCandidate { + key, + certificate_writer: None, + leaf_certificate_der: None, + }; + validate_signing_key_info(key_info, &candidate)?; + Ok(candidate) + }); + match attempt { + Ok(candidate) => return Ok(candidate), + Err(error) if lax && lax_candidate_error_is_recoverable(&error) => { + last_error = Some(error); + } + Err(error) => return Err(error), + } + } + Err(last_error.unwrap_or_else(|| CommandError::Usage("no compatible signing key".into()))) +} + fn select_signing_key( invocation: &Invocation, requested_names: &[String], @@ -813,7 +986,13 @@ fn select_signing_key( let key_options: &[&str] = if hmac { &["hmac-key"] } else { - &["privkey-pem", "privkey-der", "pkcs8-pem", "pkcs8-der"] + &[ + "privkey-pem", + "privkey-der", + "pkcs8-pem", + "pkcs8-der", + "pkcs12", + ] }; let key_kind = if hmac { "HMAC key" } else { "private key" }; let keys = invocation @@ -824,7 +1003,7 @@ fn select_signing_key( return Err(CommandError::Usage(if hmac { "HMAC signing requires --hmac-key".into() } else { - "sign requires --privkey-pem or --pkcs8-pem/der".into() + "sign requires --privkey-pem, --pkcs8-pem/der, or --pkcs12".into() })); } let candidates = named_candidate_search( @@ -891,6 +1070,39 @@ fn prepare_signing_key_candidate( leaf_certificate_der: None, }); } + if option.name == "pkcs12" { + let path = Path::new(option.value.as_deref().unwrap_or_default()); + let bytes = read_key_material_with_budget(path, material_budget)?; + let password = password + .and_then(|value| std::str::from_utf8(value).ok()) + .ok_or(key_manager::KeyStoreError::ProtectedContainer)?; + let mut inventory = KeyInventory::default(); + let name = option.parameter.clone().unwrap_or_else(|| "pkcs12".into()); + inventory.add_pkcs12(name.clone(), &bytes, password, &policy.resources)?; + let key = inventory.signing_key(&name, algorithm, policy)?; + let imported = inventory + .private_keys() + .first() + .ok_or_else(|| CommandError::Usage("PKCS#12 contains no usable private key".into()))?; + let certificate_writer = imported + .matching_certificate_chain() + .map(X509CertificateKeyInfoWriter::from_der_chain) + .transpose() + .map_err(|error| CommandError::Signature(error.to_string()))?; + if let Some(writer) = &certificate_writer { + writer + .write_key_info(key.as_ref()) + .map_err(|error| CommandError::Signature(error.to_string()))?; + } + return Ok(SigningKeyCandidate { + key, + certificate_writer, + leaf_certificate_der: imported + .matching_certificate_chain() + .and_then(|chain| chain.first()) + .cloned(), + }); + } let (path, certificate_paths) = split_key_and_certificates(option.value.as_deref().unwrap_or_default())?; let key_bytes = key_material::read(path)?; @@ -1174,7 +1386,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 +1400,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 +1436,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 +1490,65 @@ fn verify(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Command .key_resolver(&resolver) .verify(&xml) .map_err(|error| CommandError::Signature(error.to_string()))? + } else if has_key_store && algorithm.hmac_output_bits().is_some() { + let selected = select_store_candidates( + stored_keys.symmetric_keys().iter().filter(|entry| { + entry.kind == SymmetricKeyKind::Hmac + && entry.usages.allows(key_manager::KeyUsage::Verify) + }), + &signature.key_names, + lax_key_search, + policy.resources.max_key_candidates, + |entry| &entry.name, + )?; + KeyCandidateBudget::with_limit(policy.resources.max_key_candidates) + .consume(selected.len()) + .map_err(|error| CommandError::Signature(error.to_string()))?; + let candidates = selected + .into_iter() + .map(|entry| { + HmacVerificationKey::new(entry.bytes.to_vec()) + .map(ExplicitVerificationCandidate::Hmac) + .map_err(|error| CommandError::Signature(error.to_string())) + }) + .collect::, _>>()?; + let resolver = CandidateVerificationResolver::new( + candidates, + configured_certificates, + lax_key_search, + policy.key_trust.check_crls, + ); + verification_context(policy, start_node_id, &id_attributes, xml_backend) + .key_resolver(&resolver) + .verify(&xml) + .map_err(|error| CommandError::Signature(error.to_string()))? + } else if has_key_store { + let selected = select_store_candidates( + stored_keys + .public_keys() + .iter() + .filter(|entry| entry.usages.allows(key_manager::KeyUsage::Verify)), + &signature.key_names, + lax_key_search, + policy.resources.max_key_candidates, + |entry| &entry.name, + )?; + KeyCandidateBudget::with_limit(policy.resources.max_key_candidates) + .consume(selected.len()) + .map_err(|error| CommandError::Signature(error.to_string()))?; + let resolver = CandidateVerificationResolver::new( + selected + .into_iter() + .map(|entry| ExplicitVerificationCandidate::Certificate(entry.key_info.clone())) + .collect(), + configured_certificates, + lax_key_search, + policy.key_trust.check_crls, + ); + verification_context(policy, start_node_id, &id_attributes, xml_backend) + .key_resolver(&resolver) + .verify(&xml) + .map_err(|error| CommandError::Signature(error.to_string()))? } else { let config = configured_certificates.into_resolver_config(); let resolver = DefaultKeyResolver::new(config); @@ -1363,12 +1642,46 @@ impl ExternalMaterialBudget { })?; Ok(()) } + + fn remaining(&self) -> usize { + self.maximum_bytes - self.total_bytes + } +} + +fn read_key_material_with_budget( + path: &Path, + budget: &mut ExternalMaterialBudget, +) -> Result, CommandError> { + let remaining = budget.remaining(); + let bytes = key_material::read_with_limit(path, remaining).map_err(|error| { + if remaining < key_material::KEY_MATERIAL_BYTE_CEILING + && matches!( + error, + key_material::KeyMaterialError::KeyMaterialTooLarge { .. } + ) + { + CommandError::ExternalMaterialTooLarge { + maximum: budget.maximum_bytes, + } + } else { + error.into() + } + })?; + budget.charge(bytes.len())?; + Ok(bytes) } fn lax_candidate_error_is_recoverable(error: &CommandError) -> bool { - // Lax lookup may skip an unusable candidate, but an invocation-wide - // resource ceiling is terminal rather than a property of that candidate. - !matches!(error, CommandError::ExternalMaterialTooLarge { .. }) + // Lax lookup may skip an unusable candidate, not an invocation-wide + // resource failure or a failed protected-container authentication. + !matches!( + error, + CommandError::ExternalMaterialTooLarge { .. } + | CommandError::KeyStore(key_manager::KeyStoreError::ProtectedContainer) + | CommandError::KeyStore(key_manager::KeyStoreError::Policy(_)) + | CommandError::Key(key_material::KeyMaterialError::ProtectedContainer) + | CommandError::Key(key_material::KeyMaterialError::Policy(_)) + ) } fn push_configured_certificate(certificates: &mut Vec>, certificate: Vec) { @@ -1783,6 +2096,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 +2157,130 @@ 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 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); + public_keys_by_name.insert(entry.name.as_str(), entry); + } + for (recipient, metadata) in template_recipients.into_iter().zip(recipient_metadata) { + let lax = invocation.flag("lax-key-search"); + let exact = match recipient.key_name.as_deref() { + Some(name) => public_keys_by_name.get(name).copied(), + None if public_key_count == 1 => only_public_key, + 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) { + store_candidate_budget + .consume(1) + .map_err(|error| CommandError::Encryption(error.to_string()))?; + let candidate = entry + .rsa_encryption_key(&policy) + .map_err(CommandError::from) + .and_then(|public_key| { + validate_rsa_recipient_key(&public_key, &policy) + .map_err(|error| CommandError::Encryption(error.to_string()))?; + let candidate = RecipientPublicKeyCandidate { + public_key, + certificate_der: None, + }; + validate_recipient_key_metadata(metadata.as_ref(), &candidate)?; + Ok(candidate) + }); + match candidate { + Ok(candidate) => { + selected = Some((entry, candidate)); + break; + } + Err(error) => last_error = Some(error), + } + } + let (entry, candidate) = selected.ok_or_else(|| { + last_error.unwrap_or_else(|| { + CommandError::Usage("no compatible RSA key in --keys-file".into()) + }) + })?; + 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 { @@ -2393,6 +2836,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 +2917,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"), + ) && template_name.text() != generated_name.text() + { + 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 +3079,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 +3280,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 +3292,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 +3333,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 +3351,49 @@ fn decrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman &id_attributes, xml_backend, )? + } else if has_key_store { + let mut budget = + ExternalMaterialBudget::new(policy.resources.max_external_resource_total_bytes); + let store = load_xml_key_stores(invocation, &policy, xml_backend, &mut budget)?; + if !recipient_key_names.is_empty() + && !store.symmetric_keys().iter().any(|entry| { + entry.kind == SymmetricKeyKind::Aes + && entry.usages.allows(key_manager::KeyUsage::Decrypt) + }) + { + return Err(CommandError::Usage( + "--keys-file does not supply RSA recipient private keys for decrypt".into(), + )); + } + let requested_names = content_key_name.iter().cloned().collect::>(); + let selected = select_store_candidates( + store.symmetric_keys().iter().filter(|entry| { + entry.kind == SymmetricKeyKind::Aes + && entry.usages.allows(key_manager::KeyUsage::Decrypt) + }), + &requested_names, + invocation.flag("lax-key-search"), + policy.resources.max_key_candidates, + |entry| &entry.name, + )?; + KeyCandidateBudget::with_limit(policy.resources.max_key_candidates) + .consume(selected.len()) + .map_err(|error| CommandError::Encryption(error.to_string()))?; + let resolver = CandidateSymmetricKeyDecryptor { + keys: selected + .into_iter() + .map(|entry| std::borrow::Cow::Borrowed(entry.bytes.as_slice())) + .collect(), + }; + decrypt_input( + &resolver, + &xml, + encrypted_data_id, + standalone, + policy, + &id_attributes, + xml_backend, + )? } else if !private_keys.is_empty() { let selected = select_recipient_private_keys( &private_keys, @@ -2873,14 +3410,40 @@ fn decrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman ExternalMaterialBudget::new(policy.resources.max_external_resource_total_bytes); for option in selected { let loaded = (|| { + if option.name == "pkcs12" { + let path = Path::new(option.value.as_deref().unwrap_or_default()); + let bytes = read_key_material_with_budget(path, &mut certificate_budget)?; + let password = password + .and_then(|value| std::str::from_utf8(value).ok()) + .ok_or(key_manager::KeyStoreError::ProtectedContainer)?; + let mut inventory = KeyInventory::default(); + let name = option.parameter.clone().unwrap_or_else(|| "pkcs12".into()); + inventory.add_pkcs12(name.clone(), &bytes, password, &policy.resources)?; + let imported = inventory.private_keys().first().ok_or_else(|| { + CommandError::Usage("PKCS#12 contains no usable private key".into()) + })?; + let private_key = key_material::decode_rsa_private_with_password( + path, + &imported.pkcs8_der, + key_material::PrivateKeyFormat::Pkcs8Der, + None, + &policy.resources, + )?; + return Ok(RecipientPrivateKey { + inner: PrivateKeyDecryptor::new(private_key), + key_name: option.parameter.clone(), + }); + } let (path, certificate_paths) = split_key_and_certificates(option.value.as_deref().unwrap_or_default())?; - let bytes = key_material::read(path)?; - certificate_budget.charge(bytes.len())?; - let private_key = key_material::decode_rsa_private( + let bytes = + read_key_material_with_budget(Path::new(path), &mut certificate_budget)?; + let private_key = key_material::decode_rsa_private_with_password( Path::new(path), &bytes, private_key_format(option), + password, + &policy.resources, )?; if !certificate_paths.is_empty() { let encoding = if matches!( @@ -2934,7 +3497,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 +3570,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 +3596,7 @@ impl DecryptionKeyResolver for CandidateSymmetricKeyDecryptor { ) -> Result>, XmlEncError> { if encrypted_key.is_none() { budget.consume(self.keys.len())?; - Ok(self.keys.clone()) + Ok(self.keys.iter().map(|key| key.as_ref().to_vec()).collect()) } else { Err(XmlEncError::KeyNotFound) } @@ -3517,12 +4083,41 @@ 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 lax_key_search_stops_on_password_and_policy_failures() { + // Candidate search may skip incompatible keys, never terminal + // authentication or operation-wide policy failures. + assert!(!lax_candidate_error_is_recoverable(&CommandError::Key( + key_material::KeyMaterialError::ProtectedContainer, + ))); + assert!(!lax_candidate_error_is_recoverable( + &CommandError::KeyStore(key_manager::KeyStoreError::Policy( + xml_sec::policy::PolicyViolation::ResourceLimit { + resource: "external resource bytes", + maximum: 1, + actual: 2, + }, + ),) + )); + assert!(!lax_candidate_error_is_recoverable(&CommandError::Key( + key_material::KeyMaterialError::Policy( + xml_sec::policy::PolicyViolation::ResourceLimit { + resource: "external resource bytes", + maximum: 1, + actual: 2, + }, + ), + ))); + } + #[test] fn compatibility_signing_policy_includes_every_implemented_algorithm() { // An explicit allowlist replaces, rather than extends, secure defaults. @@ -3569,6 +4164,364 @@ mod tests { .join(name) } + #[test] + fn named_store_encryption_falls_back_only_when_lax() { + // An absent named key may fall back in lax mode, but an exact match wins. + let names = ["fallback", "exact"]; + let requested = vec!["exact".to_string()]; + assert_eq!( + select_store_candidates(names.iter(), &requested, true, 2, |name| name).unwrap()[0], + &"exact" + ); + let absent = vec!["absent".to_string()]; + assert!(select_store_candidates(names.iter(), &absent, false, 2, |name| name).is_err()); + assert_eq!( + select_store_candidates(names.iter(), &absent, true, 2, |name| name).unwrap()[0], + &"fallback" + ); + } + + #[test] + fn store_selection_bounds_inspected_candidates() { + // A name filter cannot make scanning an oversized candidate pool free. + let names = ["first", "second"]; + let requested = vec!["second".to_owned()]; + assert!(matches!( + select_store_candidates(names.iter(), &requested, false, 1, |name| name), + Err(CommandError::KeyStore(key_manager::KeyStoreError::Policy( + xml_sec::policy::PolicyViolation::ResourceLimit { + resource: "key candidates", + maximum: 1, + actual: 2, + } + ))) + )); + assert_eq!( + select_store_candidates(names.iter(), &requested, false, 2, |name| name).unwrap(), + vec![&"second"] + ); + } + + #[test] + fn cli_lax_store_encryption_accepts_missing_template_key_name() { + // Exercise the command boundary: a present but unknown KeyName must + // fall back only when --lax-key-search was explicitly requested. + let temp = tempfile::tempdir().expect("temporary test directory"); + let template_path = temp.path().join("template.xml"); + let input_path = temp.path().join("input.bin"); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/xmlenc/aleksey-xmlenc-01/enc-aes128gcm-keyname.tmpl"); + let template = fs::read_to_string(fixture) + .expect("encryption template fixture") + .replace("test-aes128", "absent"); + fs::write(&template_path, template).expect("write encryption template"); + fs::write(&input_path, b"lax fallback payload").expect("write plaintext"); + let store = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keys/xmlsec/mixed-keys.xml"); + let args = [ + "xmlsec1", + "encrypt", + "--keys-file", + store.to_str().expect("fixture path is UTF-8"), + "--binary-data", + input_path.to_str().expect("input path is UTF-8"), + template_path.to_str().expect("template path is UTF-8"), + ]; + assert!(execute(invocation(&args), &mut Vec::new(), &mut Vec::new()).is_err()); + let mut lax_args = vec!["xmlsec1", "encrypt", "--lax-key-search"]; + lax_args.extend_from_slice(&args[2..]); + let mut output = Vec::new(); + execute(invocation(&lax_args), &mut output, &mut Vec::new()) + .expect("lax store encryption finds alternate AES key"); + assert!(String::from_utf8_lossy(&output).contains("CipherValue")); + } + + #[test] + fn cli_lax_store_encryption_skips_ineligible_aes_key() { + // A fallback candidate with the wrong AES length must not hide a later usable key. + let temp = tempfile::tempdir().expect("temporary test directory"); + let template_path = temp.path().join("template.xml"); + let input_path = temp.path().join("input.bin"); + let store_path = temp.path().join("keys.xml"); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/xmlenc/aleksey-xmlenc-01/enc-aes128gcm-keyname.tmpl"); + let template = fs::read_to_string(fixture) + .expect("encryption template fixture") + .replace("test-aes128", "absent"); + fs::write(&template_path, template).expect("write encryption template"); + fs::write(&input_path, b"lax fallback payload").expect("write plaintext"); + let source = fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keys/xmlsec/mixed-keys.xml"), + ) + .expect("key store fixture"); + let extra = "wrong-aes192AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + fs::write( + &store_path, + source.replacen("lax RSA fallback payload").expect("write plaintext"); + let pem = fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"), + ) + .expect("public key fixture"); + let public_key = RsaPublicKey::from_public_key_pem(&pem).expect("RSA public key"); + let encode = |bytes: Vec| base64::engine::general_purpose::STANDARD.encode(bytes); + let extra = format!( + "valid-rsa{}{}", + encode(public_key.n().to_be_bytes_trimmed_vartime().into_vec()), + encode(public_key.e().to_be_bytes_trimmed_vartime().into_vec()) + ); + let source = fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keys/xmlsec/mixed-keys.xml"), + ) + .expect("key store fixture"); + fs::write( + &store_path, + source.replacen("
", &format!("{extra}
"), 1), + ) + .expect("write key store"); + let args = [ + "xmlsec1", + "encrypt", + "--lax-key-search", + "--keys-file", + store_path.to_str().expect("store path is UTF-8"), + "--xml-data", + input_path.to_str().expect("input path is UTF-8"), + template_path.to_str().expect("template path is UTF-8"), + ]; + let mut output = Vec::new(); + execute(invocation(&args), &mut output, &mut Vec::new()) + .expect("lax search skips RSA-1024 before RSA-2048"); + assert!(String::from_utf8_lossy(&output).contains("CipherValue")); + let encrypted = temp.path().join("encrypted.xml"); + fs::write(&encrypted, &output).expect("write encrypted output"); + let private = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keys/rsa/rsa-2048-key.pem"); + let decrypt_args = [ + "xmlsec1", + "decrypt", + "--privkey-pem:valid-rsa", + private.to_str().expect("key path is UTF-8"), + encrypted.to_str().expect("encrypted path is UTF-8"), + ]; + let mut decrypted = Vec::new(); + execute(invocation(&decrypt_args), &mut decrypted, &mut Vec::new()) + .expect("strict decryption uses the fallback recipient name"); + assert_eq!(decrypted, b"lax RSA fallback payload"); + } + + #[test] + fn store_signing_retries_key_info_mismatch_in_lax_mode() { + // An algorithm-compatible key is not a valid match for embedded KeyInfo. + let mut inventory = KeyInventory::default(); + let policy = SigningPolicy::default(); + let wrong = std::fs::read( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keys/rsa/rsa-2048-key.pem"), + ) + .unwrap(); + let right = std::fs::read( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keys/rsa/rsa-4096-key.pem"), + ) + .unwrap(); + for (name, pem) in [("wrong", wrong), ("right", right)] { + inventory + .add_private_pem( + name.into(), + &pem, + None, + key_manager::KeyUsages::SIGN, + &policy.resources, + ) + .unwrap(); + } + let right_spki = inventory + .signing_key("right", SignatureAlgorithm::RsaSha256, &policy) + .unwrap() + .public_key_info() + .unwrap() + .spki_der() + .unwrap() + .to_vec(); + let mut info = KeyInfo::default(); + info.sources + .push(KeyInfoSource::DerEncodedKeyValue(right_spki)); + assert!( + select_store_signing_key( + &inventory, + ["wrong"], + SignatureAlgorithm::RsaSha256, + Some(&info), + &policy, + false, + ) + .is_err() + ); + assert!( + select_store_signing_key( + &inventory, + ["wrong", "right"], + SignatureAlgorithm::RsaSha256, + Some(&info), + &policy, + true, + ) + .is_ok() + ); + } + + #[test] + fn lax_store_signing_shares_lookup_budget_across_retries() { + // Three independent scans cost 1 + 2 + 3 inspections, not three. + let mut inventory = KeyInventory::default(); + let mut policy = SigningPolicy::default(); + let wrong = fs::read( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keys/rsa/rsa-2048-key.pem"), + ) + .unwrap(); + let right = fs::read( + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keys/rsa/rsa-4096-key.pem"), + ) + .unwrap(); + for (name, pem) in [("wrong-a", &wrong), ("wrong-b", &wrong), ("right", &right)] { + inventory + .add_private_pem( + name.into(), + pem, + None, + key_manager::KeyUsages::SIGN, + &policy.resources, + ) + .unwrap(); + } + let right_spki = inventory + .signing_key("right", SignatureAlgorithm::RsaSha256, &policy) + .unwrap() + .public_key_info() + .unwrap() + .spki_der() + .unwrap() + .to_vec(); + let mut info = KeyInfo::default(); + info.sources + .push(KeyInfoSource::DerEncodedKeyValue(right_spki)); + policy.resources.max_key_candidates = 3; + assert!( + select_store_signing_key( + &inventory, + ["wrong-a", "wrong-b", "right"], + SignatureAlgorithm::RsaSha256, + Some(&info), + &policy, + true, + ) + .is_err() + ); + } + + #[test] + fn strict_store_signing_requires_template_key_name() { + // A singleton store must not silently authorize an unnamed template. + let temp = tempfile::tempdir().expect("temporary signing template"); + let template = temp.path().join("unsigned.xml"); + fs::write( + &template, + br#""#, + ) + .expect("write template"); + let template = template.to_str().expect("UTF-8 path"); + let store = temp.path().join("keys.xml"); + fs::write( + &store, + br#"only-keyc2VjcmV0"#, + ) + .expect("write singleton store"); + let store = store.to_str().expect("UTF-8 path"); + let strict = invocation(&["xmlsec1", "sign", "--keys-file", store, template]); + let error = sign(&strict, &mut Vec::new()).expect_err("strict mode requires KeyName"); + assert!( + error.to_string().contains("requires a template KeyName"), + "{error}" + ); + let lax = invocation(&[ + "xmlsec1", + "sign", + "--lax-key-search", + "--keys-file", + store, + template, + ]); + let mut signed = Vec::new(); + sign(&lax, &mut signed).expect("lax mode may select the unnamed singleton"); + assert!(String::from_utf8_lossy(&signed).contains("DigestValue")); + } + + #[test] + fn pkcs12_signing_ignores_unrelated_ca_certificate() { + // A CA-only bundle still provides its private signing key, without a leaf writer. + let bundle = base64::engine::general_purpose::STANDARD + .decode( + include_str!("../../../tests/fixtures/keys/pkcs12/rsa-key-unrelated-ca.p12.b64") + .trim(), + ) + .unwrap(); + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("key.p12"); + fs::write(&path, bundle).unwrap(); + let parsed = Invocation::parse([ + OsString::from("xmlsec1"), + OsString::from("sign"), + OsString::from("--pkcs12"), + path.into_os_string(), + OsString::from("template.xml"), + ]) + .unwrap(); + let option = parsed.values("pkcs12").next().unwrap(); + let mut budget = ExternalMaterialBudget::new(usize::MAX); + let candidate = prepare_signing_key_candidate( + option, + SignatureAlgorithm::RsaSha256, + &SigningPolicy::default(), + Some(b"secret"), + &mut budget, + ) + .unwrap(); + assert!(candidate.certificate_writer.is_none()); + assert!(candidate.leaf_certificate_der.is_none()); + } + struct CountingVerificationKey { accepts: bool, calls: Rc>, @@ -3984,6 +4937,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..e04e56ef 100644 --- a/tools/xmlsec1/src/key_material.rs +++ b/tools/xmlsec1/src/key_material.rs @@ -23,7 +23,8 @@ use rsa::{ }, }; use x509_parser::prelude::FromDer as _; -use xml_sec::policy::{PolicyViolation, SigningPolicy, VerificationPolicy}; +use xml_sec::key_manager::{KeyInventory, KeyUsages}; +use xml_sec::policy::{PolicyViolation, ResourcePolicy, SigningPolicy, VerificationPolicy}; use xml_sec::xmldsig::{ DsaSigningKey, DsigError, EcdsaP256SigningKey, EcdsaP384SigningKey, EcdsaP521SigningKey, KeyInfo, ReferenceProcessingError, RsaSigningKey, SignatureAlgorithm, SigningKey, @@ -38,7 +39,7 @@ use zeroize::Zeroizing; // This is an absolute process-safety ceiling, not deployment policy. Parsed // key sizes remain governed by the operation policy after bounded ingestion. -const KEY_MATERIAL_BYTE_CEILING: usize = 8 * 1024 * 1024; +pub(crate) const KEY_MATERIAL_BYTE_CEILING: usize = 8 * 1024 * 1024; const MAX_AES_KEY_BYTES: usize = 32; #[derive(Debug, thiserror::Error)] @@ -52,6 +53,8 @@ pub enum KeyMaterialError { InvalidPem(PathBuf), #[error("unsupported private key in {}", .0.display())] UnsupportedPrivateKey(PathBuf), + #[error("protected key container could not be decoded")] + ProtectedContainer, #[error("unsupported public key in {}", .0.display())] UnsupportedPublicKey(PathBuf), #[error("invalid X.509 certificate in {}", .0.display())] @@ -115,23 +118,31 @@ pub enum CertificateEncoding { } pub fn read(path: impl AsRef) -> Result, KeyMaterialError> { + read_with_limit(path, KEY_MATERIAL_BYTE_CEILING) +} + +pub fn read_with_limit( + path: impl AsRef, + maximum_bytes: usize, +) -> Result, KeyMaterialError> { let path = path.as_ref(); - let mut bytes = Vec::with_capacity(KEY_MATERIAL_BYTE_CEILING.min(64 * 1024)); + let maximum = maximum_bytes.min(KEY_MATERIAL_BYTE_CEILING); + let mut bytes = Vec::with_capacity(maximum.min(64 * 1024)); File::open(path) .map_err(|source| KeyMaterialError::Read { path: path.to_owned(), source, })? - .take(KEY_MATERIAL_BYTE_CEILING.saturating_add(1) as u64) + .take(maximum.saturating_add(1) as u64) .read_to_end(&mut bytes) .map_err(|source| KeyMaterialError::Read { path: path.to_owned(), source, })?; - if bytes.len() > KEY_MATERIAL_BYTE_CEILING { + if bytes.len() > maximum { return Err(KeyMaterialError::KeyMaterialTooLarge { path: path.to_owned(), - maximum: KEY_MATERIAL_BYTE_CEILING, + maximum, }); } Ok(bytes) @@ -643,8 +654,16 @@ fn decode_traditional_rsa_pem( path: &Path, ) -> Result { let der = decode_openssl_traditional_pem(text, "RSA PRIVATE KEY", password, path)?; - RsaPrivateKey::from_pkcs1_der(&der) - .map_err(|_| KeyMaterialError::UnsupportedPrivateKey(path.to_owned())) + RsaPrivateKey::from_pkcs1_der(&der).map_err(|_| { + if pem::parse(text) + .ok() + .is_some_and(|block| block.headers().get("Proc-Type") == Some("4,ENCRYPTED")) + { + KeyMaterialError::ProtectedContainer + } else { + KeyMaterialError::UnsupportedPrivateKey(path.to_owned()) + } + }) } fn decode_openssl_traditional_pem( @@ -684,8 +703,7 @@ fn decode_openssl_traditional_pem( .ok_or_else(|| KeyMaterialError::UnsupportedPrivateKey(path.to_owned()))?; let iv = decode_hex(encoded_iv) .ok_or_else(|| KeyMaterialError::UnsupportedPrivateKey(path.to_owned()))?; - let password = - password.ok_or_else(|| KeyMaterialError::UnsupportedPrivateKey(path.to_owned()))?; + let password = password.ok_or(KeyMaterialError::ProtectedContainer)?; decrypt_openssl_legacy_pem(cipher, &iv, envelope.contents(), password, path) } @@ -735,7 +753,7 @@ fn decrypt_openssl_legacy_pem( let length = cbc::Decryptor::<$cipher>::new_from_slices(&key, iv) .map_err(|_| KeyMaterialError::UnsupportedPrivateKey(path.to_owned()))? .decrypt_padded::(&mut plaintext) - .map_err(|_| KeyMaterialError::UnsupportedPrivateKey(path.to_owned()))? + .map_err(|_| KeyMaterialError::ProtectedContainer)? .len(); plaintext.truncate(length); }}; @@ -894,17 +912,66 @@ pub fn load_rsa_private( /// Decode caller-owned RSA private-key bytes after the operation layer has /// charged their source length to its aggregate external-material budget. +#[cfg(test)] pub fn decode_rsa_private( path: &Path, bytes: &[u8], format: PrivateKeyFormat, ) -> Result { + decode_rsa_private_with_password(path, bytes, format, None, &ResourcePolicy::default()) +} + +/// Decode an RSA transport key without retrying plaintext formats after a +/// protected container fails password verification. +pub fn decode_rsa_private_with_password( + path: &Path, + bytes: &[u8], + format: PrivateKeyFormat, + password: Option<&[u8]>, + resources: &ResourcePolicy, +) -> Result { + if pkcs8_container_kind(bytes, format) == Some(Pkcs8ContainerKind::Encrypted) { + let password = password.ok_or(KeyMaterialError::ProtectedContainer)?; + let mut inventory = KeyInventory::default(); + let imported = match format { + PrivateKeyFormat::Pem | PrivateKeyFormat::Pkcs8Pem => inventory.add_private_pem( + "cli-rsa".into(), + bytes, + Some(password), + KeyUsages::DECRYPT, + resources, + ), + PrivateKeyFormat::Der | PrivateKeyFormat::Pkcs8Der => inventory.add_private_der( + "cli-rsa".into(), + bytes, + Some(password), + KeyUsages::DECRYPT, + resources, + ), + }; + imported.map_err(|error| match error { + xml_sec::key_manager::KeyStoreError::ProtectedContainer => { + KeyMaterialError::ProtectedContainer + } + xml_sec::key_manager::KeyStoreError::Policy(violation) => violation.into(), + _ => KeyMaterialError::UnsupportedPrivateKey(path.to_owned()), + })?; + return inventory + .private_keys() + .first() + .and_then(|entry| RsaPrivateKey::from_pkcs8_der(&entry.pkcs8_der).ok()) + .ok_or_else(|| KeyMaterialError::UnsupportedPrivateKey(path.to_owned())); + } match format { - PrivateKeyFormat::Pem => std::str::from_utf8(bytes).ok().and_then(|text| { - RsaPrivateKey::from_pkcs8_pem(text) - .or_else(|_| RsaPrivateKey::from_pkcs1_pem(text)) - .ok() - }), + PrivateKeyFormat::Pem => { + let text = std::str::from_utf8(bytes) + .map_err(|_| KeyMaterialError::UnsupportedPrivateKey(path.to_owned()))?; + if pkcs8_container_kind(bytes, format) == Some(Pkcs8ContainerKind::Plain) { + RsaPrivateKey::from_pkcs8_pem(text).ok() + } else { + return decode_traditional_rsa_pem(text, password, path); + } + } PrivateKeyFormat::Der => RsaPrivateKey::from_pkcs8_der(bytes) .or_else(|_| RsaPrivateKey::from_pkcs1_der(bytes)) .ok(), @@ -1021,6 +1088,68 @@ mod tests { use super::*; + #[test] + fn protected_rsa_container_failure_is_not_a_lax_candidate_miss() { + // A wrong or missing password must stop lax search before a later + // unprotected candidate can silently replace the requested key. + let rsa = RsaPrivateKey::from_pkcs8_pem(include_str!( + "../../../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("RSA fixture"); + let plain = rsa.to_pkcs8_der().expect("PKCS#8 fixture"); + let mut rng = ChaCha20Rng::seed_from_u64(0xA11C_E501); + let encrypted = PrivateKeyInfoRef::try_from(plain.as_bytes()) + .expect("PKCS#8 reference") + .encrypt_with_rng(&mut rng, b"correct") + .expect("encrypted fixture"); + for password in [None, Some(b"wrong".as_slice())] { + assert!(matches!( + decode_rsa_private_with_password( + Path::new("protected.der"), + encrypted.as_bytes(), + PrivateKeyFormat::Pkcs8Der, + password, + &ResourcePolicy::default(), + ), + Err(KeyMaterialError::ProtectedContainer) + )); + } + let invalid_policy = ResourcePolicy { + max_external_resource_bytes: usize::MAX, + ..ResourcePolicy::default() + }; + assert!(matches!( + decode_rsa_private_with_password( + Path::new("protected.der"), + encrypted.as_bytes(), + PrivateKeyFormat::Pkcs8Der, + Some(b"correct"), + &invalid_policy, + ), + Err(KeyMaterialError::Policy(_)) + )); + } + + #[test] + fn traditional_encrypted_rsa_pem_preserves_password_failure() { + // A protected traditional PEM must not look like a missing key to lax selection. + let pem = include_bytes!( + "../../../tests/fixtures/keys/rsa/rsa-2048-key-traditional-encrypted.pem" + ); + for password in [None, Some(b"wrong".as_slice())] { + assert!(matches!( + decode_rsa_private_with_password( + Path::new("protected.pem"), + pem, + PrivateKeyFormat::Pem, + password, + &ResourcePolicy::default(), + ), + Err(KeyMaterialError::ProtectedContainer) + )); + } + } + fn load_signing_key( path: impl AsRef, format: PrivateKeyFormat, diff --git a/tools/xmlsec1/tests/process_contract.rs b/tools/xmlsec1/tests/process_contract.rs index d3449f22..55de1eec 100644 --- a/tools/xmlsec1/tests/process_contract.rs +++ b/tools/xmlsec1/tests/process_contract.rs @@ -17,6 +17,7 @@ use rcgen::{ }; use rsa::{ RsaPrivateKey, RsaPublicKey, + pkcs1::DecodeRsaPrivateKey as _, pkcs8::{ DecodePrivateKey as _, DecodePublicKey as _, EncodePrivateKey as _, EncodePublicKey as _, }, @@ -46,6 +47,230 @@ fn project_root() -> &'static Path { Path::new(env!("CARGO_MANIFEST_DIR")) } +#[test] +fn donor_pkcs12_decrypts_and_wrong_password_fails_closed() { + // The PHAOS bundle and ciphertext are independent xmlsec1 oracle inputs. + let fixture = project_root().join("tests/fixtures/xmlenc/01-phaos-xmlenc-3"); + let encrypted = fixture.join("enc-element-aes128-kt-rsa_oaep_sha1.xml"); + let key = fixture.join("rsa-priv-key.p12"); + let run = |password: &str| { + Command::new(binary()) + .arg("decrypt") + .arg("--pkcs12:my-rsa-key") + .arg(&key) + .arg("--pwd") + .arg(password) + .arg(&encrypted) + .output() + .unwrap() + }; + let success = run("secret"); + assert!( + success.status.success(), + "{}", + String::from_utf8_lossy(&success.stderr) + ); + assert!(String::from_utf8_lossy(&success.stdout).contains("CreditCard")); + + let failure = run("wrong-password"); + assert!(!failure.status.success()); + assert!(!String::from_utf8_lossy(&failure.stderr).contains("wrong-password")); +} + +#[test] +fn donor_pkcs12_signs_without_exposing_password() { + // The PKCS#12 importer must feed the normal signing pipeline, not just RSA + // transport decryption, and a wrong password must not retry plaintext DER. + let fixture = project_root().join("tests/fixtures/xmlenc/01-phaos-xmlenc-3"); + let temp = tempfile::tempdir().unwrap(); + let template = temp.path().join("template.xml"); + let signed = temp.path().join("signed.xml"); + let public = temp.path().join("public.der"); + fs::write(&template, signature_template_without_key_info()).unwrap(); + let private = + RsaPrivateKey::from_pkcs1_der(&fs::read(fixture.join("rsa-priv-key.der")).unwrap()) + .unwrap(); + fs::write( + &public, + private + .to_public_key() + .to_public_key_der() + .unwrap() + .as_bytes(), + ) + .unwrap(); + let key = fixture.join("rsa-priv-key.p12"); + let sign = |password: &str| { + Command::new(binary()) + .arg("sign") + .arg("--pkcs12") + .arg(&key) + .arg("--pwd") + .arg(password) + .arg("--output") + .arg(&signed) + .arg(&template) + .output() + .unwrap() + }; + let success = sign("secret"); + assert!( + success.status.success(), + "{}", + String::from_utf8_lossy(&success.stderr) + ); + let verified = Command::new(binary()) + .arg("verify") + .arg("--pubkey-der") + .arg(&public) + .arg(&signed) + .output() + .unwrap(); + assert!( + verified.status.success(), + "{}", + String::from_utf8_lossy(&verified.stderr) + ); + + let failed = sign("wrong-password"); + assert!(!failed.status.success()); + assert!(!String::from_utf8_lossy(&failed.stderr).contains("wrong-password")); +} + +#[test] +fn lax_signing_stops_on_protected_pkcs12_failure() { + // A wrong container password is an invocation failure, not permission to + // use a later unprotected signing key from the lax candidate list. + let fixture = project_root().join("tests/fixtures/xmlenc/01-phaos-xmlenc-3"); + let temp = tempfile::tempdir().unwrap(); + let template = temp.path().join("template.xml"); + fs::write(&template, signature_template_without_key_info()).unwrap(); + let result = Command::new(binary()) + .args(["sign", "--lax-key-search", "--pkcs12:first"]) + .arg(fixture.join("rsa-priv-key.p12")) + .args(["--privkey-pem:second"]) + .arg(project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem")) + .args(["--pwd", "wrong-password"]) + .arg(&template) + .output() + .unwrap(); + assert!( + !result.status.success(), + "wrong PKCS#12 password was skipped" + ); + assert!(!String::from_utf8_lossy(&result.stderr).contains("wrong-password")); +} + +#[test] +fn lax_decryption_stops_on_protected_pkcs12_failure() { + // A protected-container authentication failure must not be bypassed by + // decrypting with a later plaintext private-key candidate. + let fixture = project_root().join("tests/fixtures/xmlenc/01-phaos-xmlenc-3"); + let result = Command::new(binary()) + .args(["decrypt", "--lax-key-search", "--pkcs12:my-rsa-key"]) + .arg(fixture.join("rsa-priv-key.p12")) + .args(["--privkey-der:second"]) + .arg(fixture.join("rsa-priv-key.der")) + .args(["--pwd", "wrong-password"]) + .arg(fixture.join("enc-element-aes128-kt-rsa_oaep_sha1.xml")) + .output() + .unwrap(); + assert!( + !result.status.success(), + "wrong PKCS#12 password was skipped" + ); + assert!(!String::from_utf8_lossy(&result.stderr).contains("wrong-password")); +} + +#[test] +fn lax_decryption_stops_on_traditional_encrypted_rsa_pem_failure() { + // A wrong password for the first protected RSA candidate cannot authorize + // fallback to a later unprotected key for the same recipient. + let temp = tempfile::tempdir().unwrap(); + let template = temp.path().join("template.xml"); + let plaintext = temp.path().join("plaintext.bin"); + let encrypted = temp.path().join("encrypted.xml"); + let keys = project_root().join("tests/fixtures/keys/rsa"); + fs::write(&template, r#""#).unwrap(); + fs::write(&plaintext, b"protected RSA recipient").unwrap(); + let encrypt = Command::new(binary()) + .args(["encrypt", "--pubkey-pem"]) + .arg(keys.join("rsa-2048-pubkey.pem")) + .arg("--binary-data") + .arg(&plaintext) + .arg("--output") + .arg(&encrypted) + .arg(&template) + .output() + .unwrap(); + assert!( + encrypt.status.success(), + "{}", + String::from_utf8_lossy(&encrypt.stderr) + ); + let decrypt = Command::new(binary()) + .args(["decrypt", "--lax-key-search", "--privkey-pem:first"]) + .arg(keys.join("rsa-2048-key-traditional-encrypted.pem")) + .arg("--privkey-pem:second") + .arg(keys.join("rsa-2048-key.pem")) + .args(["--pwd", "wrong-legacy-password-sentinel"]) + .arg(&encrypted) + .output() + .unwrap(); + assert!(!decrypt.status.success()); + assert!(!String::from_utf8_lossy(&decrypt.stderr).contains("wrong-legacy-password-sentinel")); +} + +#[test] +fn donor_xml_store_private_dsa_signs_and_public_dsa_verifies() { + // The upstream xmlsec extension carries DSA X only in the store. The + // signature document names the key but does not contain secret material. + let temp = tempfile::tempdir().unwrap(); + let template = temp.path().join("dsa-template.xml"); + let signed = temp.path().join("dsa-signed.xml"); + let source = signature_template_without_key_info() + .replace( + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "http://www.w3.org/2000/09/xmldsig#dsa-sha1", + ) + .replace( + "http://www.w3.org/2001/04/xmlenc#sha256", + "http://www.w3.org/2000/09/xmldsig#sha1", + ) + .replace( + "", + "test-dsa", + ); + fs::write(&template, source).unwrap(); + let store = project_root().join("tests/fixtures/keys/xmlsec/mixed-keys.xml"); + let sign = Command::new(binary()) + .arg("sign") + .arg("--keys-file") + .arg(&store) + .arg("--output") + .arg(&signed) + .arg(&template) + .output() + .unwrap(); + assert!( + sign.status.success(), + "{}", + String::from_utf8_lossy(&sign.stderr) + ); + let verify = Command::new(binary()) + .arg("verify") + .arg("--keys-file") + .arg(&store) + .arg(&signed) + .output() + .unwrap(); + assert!( + verify.status.success(), + "{}", + String::from_utf8_lossy(&verify.stderr) + ); +} + #[derive(der::Sequence)] struct TraditionalDsaPrivateKey<'a> { version: u8, @@ -444,6 +669,154 @@ fn compatibility_cli_signs_hmac_templates_with_named_raw_keys() { assert!(String::from_utf8_lossy(&rejected_verify.stderr).contains("configured minimum")); } +#[test] +fn key_store_supplies_named_hmac_key_for_sign_and_verify() { + // A libxmlsec1 key store is an input key source, not merely output of the + // `keys` command. A missing or malformed store must not fall back to an + // unrelated key. + let temp = tempfile::tempdir().unwrap(); + let template = project_root().join( + "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1.tmpl", + ); + let key_store = temp.path().join("keys.xml"); + let signed = temp.path().join("signed.xml"); + let secret = fs::read(project_root().join("tests/fixtures/keys/hmackey.bin")).unwrap(); + let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, secret); + fs::write( + &key_store, + format!( + "TeskKeyName-Hmac{encoded}" + ), + ) + .unwrap(); + + let sign = Command::new(binary()) + .args(["sign", "--keys-file"]) + .arg(&key_store) + .arg("--output") + .arg(&signed) + .arg(&template) + .output() + .unwrap(); + assert!( + sign.status.success(), + "{}", + String::from_utf8_lossy(&sign.stderr) + ); + + let verify = Command::new(binary()) + .args(["verify", "--keys-file"]) + .arg(&key_store) + .arg(&signed) + .output() + .unwrap(); + assert!( + verify.status.success(), + "{}", + String::from_utf8_lossy(&verify.stderr) + ); + + let duplicate = Command::new(binary()) + .args(["verify", "--keys-file"]) + .arg(&key_store) + .arg("--keys-file") + .arg(&key_store) + .arg(&signed) + .output() + .unwrap(); + assert!(!duplicate.status.success()); + assert!(String::from_utf8_lossy(&duplicate.stderr).contains("duplicate key name")); + + let malformed = temp.path().join("malformed.xml"); + fs::write(&malformed, "").unwrap(); + let rejected = Command::new(binary()) + .args(["verify", "--keys-file"]) + .arg(&malformed) + .arg(&signed) + .output() + .unwrap(); + assert!(!rejected.status.success()); +} + +#[test] +fn key_store_accepts_mixed_upstream_key_types() { + // The upstream keys.xml intentionally mixes symmetric, RSA, DSA, and + // additional key families. An unsupported entry cannot invalidate a + // separately usable HMAC entry in the same store. + let template = project_root().join( + "tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-hmac-sha1.tmpl", + ); + let store = project_root().join("tests/fixtures/keys/xmlsec/mixed-keys.xml"); + let output = Command::new(binary()) + .args(["sign", "--lax-key-search", "--keys-file"]) + .arg(&store) + .arg(&template) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn lax_key_store_verification_skips_incompatible_family() { + // The first named store entry is DSA; the RSA signature must be checked + // against the later compatible entry instead of failing at the first key. + let temp = tempfile::tempdir().unwrap(); + let template = project_root() + .join("tests/fixtures/xmldsig/aleksey-xmldsig-01/enveloping-sha256-rsa-sha256.tmpl"); + let signed = temp.path().join("signed.xml"); + let sign = Command::new(binary()) + .args(["sign", "--privkey-pem"]) + .arg(project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem")) + .arg("--output") + .arg(&signed) + .arg(&template) + .output() + .unwrap(); + assert!( + sign.status.success(), + "{}", + String::from_utf8_lossy(&sign.stderr) + ); + + let donor = + fs::read_to_string(project_root().join("tests/fixtures/keys/xmlsec/mixed-keys.xml")) + .unwrap(); + let dsa_name = donor.find("test-dsa").unwrap(); + let dsa_start = donor[..dsa_name].rfind("").unwrap() + dsa_name + "".len(); + let public_pem = + fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem")) + .unwrap(); + let public = RsaPublicKey::from_public_key_pem(&public_pem).unwrap(); + let base64 = base64::engine::general_purpose::STANDARD; + let store = temp.path().join("keys.xml"); + fs::write( + &store, + format!( + "{}rsa{}{}", + &donor[dsa_start..dsa_end], + base64.encode(public.n().to_be_bytes_trimmed_vartime()), + base64.encode(public.e().to_be_bytes_trimmed_vartime()), + ), + ) + .unwrap(); + let verify = Command::new(binary()) + .args(["verify", "--lax-key-search", "--keys-file"]) + .arg(&store) + .arg(&signed) + .output() + .unwrap(); + assert!( + verify.status.success(), + "{}", + String::from_utf8_lossy(&verify.stderr) + ); +} + #[test] #[expect( deprecated, @@ -577,6 +950,28 @@ fn compatibility_cli_decodes_dsa_and_p521_pkcs8_signing_keys() { .as_bytes(), ) .unwrap(); + let compatible_private = temp.path().join("dsa-2048-private.der"); + fs::write( + &compatible_private, + dsa_key.to_pkcs8_der().unwrap().as_bytes(), + ) + .unwrap(); + let lax_signed = temp.path().join("dsa-lax-signed.xml"); + let lax_sign = Command::new(binary()) + .args(["sign", "--lax-key-search", "--pkcs8-der:wrong"]) + .arg(&legacy_private) + .arg("--pkcs8-der:TestKeyName-dsa-2048") + .arg(&compatible_private) + .arg("--output") + .arg(&lax_signed) + .arg(&dsa_template) + .output() + .unwrap(); + assert!( + lax_sign.status.success(), + "{}", + String::from_utf8_lossy(&lax_sign.stderr) + ); let donor_legacy_template = project_root() .join("tests/fixtures/xmldsig/merlin-xmldsig-twenty-three/signature-enveloping-dsa.tmpl"); let legacy_template = temp.path().join("dsa-1024-template.xml"); @@ -3072,6 +3467,393 @@ fn encrypts_decrypts_and_rejects_wrong_symmetric_key() { assert!(!rejected.status.success()); } +#[test] +fn key_store_supplies_aes_key_for_encryption_and_decryption() { + // The same named key store must serve both sides of a binary encryption + // round trip; a second store with different material must not decrypt it. + let temp = tempfile::tempdir().unwrap(); + let template = temp.path().join("template.xml"); + let plaintext = temp.path().join("plaintext.bin"); + let store = temp.path().join("keys.xml"); + let wrong_store = temp.path().join("wrong.xml"); + let encrypted = temp.path().join("encrypted.xml"); + let decrypted = temp.path().join("decrypted.bin"); + fs::write( + &template, + r#"content"#, + ) + .unwrap(); + fs::write(&plaintext, b"key-store round trip\0\xff").unwrap(); + for (path, key) in [ + (&store, b"0123456789abcdef"), + (&wrong_store, b"fedcba9876543210"), + ] { + let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key); + fs::write(path, format!("content{encoded}")).unwrap(); + } + let encrypt = Command::new(binary()) + .args(["encrypt", "--keys-file"]) + .arg(&store) + .arg("--binary-data") + .arg(&plaintext) + .arg("--output") + .arg(&encrypted) + .arg(&template) + .output() + .unwrap(); + assert!( + encrypt.status.success(), + "{}", + String::from_utf8_lossy(&encrypt.stderr) + ); + let decrypt = Command::new(binary()) + .args(["decrypt", "--keys-file"]) + .arg(&store) + .arg("--output") + .arg(&decrypted) + .arg(&encrypted) + .output() + .unwrap(); + assert!( + decrypt.status.success(), + "{}", + String::from_utf8_lossy(&decrypt.stderr) + ); + assert_eq!(fs::read(&decrypted).unwrap(), fs::read(&plaintext).unwrap()); + // An embedded recipient does not invalidate a separate direct content key. + // Both explicit and stored direct keys must take the same selection path. + let with_recipient = temp.path().join("encrypted-with-recipient.xml"); + let direct_key = temp.path().join("content.key"); + fs::write(&direct_key, b"0123456789abcdef").unwrap(); + let encrypted_xml = fs::read_to_string(&encrypted).unwrap(); + assert!(encrypted_xml.contains("")); + let encrypted_xml = encrypted_xml.replacen( + "", + "recipientAA==", + 1, + ); + fs::write(&with_recipient, encrypted_xml).unwrap(); + let explicit = Command::new(binary()) + .args(["decrypt", "--aes-key:content"]) + .arg(&direct_key) + .arg(&with_recipient) + .output() + .unwrap(); + assert!( + explicit.status.success(), + "{}", + String::from_utf8_lossy(&explicit.stderr) + ); + assert_eq!(explicit.stdout, fs::read(&plaintext).unwrap()); + let stored = Command::new(binary()) + .args(["decrypt", "--keys-file"]) + .arg(&store) + .arg(&with_recipient) + .output() + .unwrap(); + assert!( + stored.status.success(), + "{}", + String::from_utf8_lossy(&stored.stderr) + ); + assert_eq!(stored.stdout, fs::read(&plaintext).unwrap()); + let wrong = Command::new(binary()) + .args(["decrypt", "--keys-file"]) + .arg(&wrong_store) + .arg(&encrypted) + .output() + .unwrap(); + assert!(!wrong.status.success()); + + let mixed_store = temp.path().join("mixed.xml"); + let wrong_encoded = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + b"fedcba9876543210", + ); + let right_encoded = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + b"0123456789abcdef", + ); + fs::write( + &mixed_store, + format!("wrong{wrong_encoded}content{right_encoded}"), + ) + .unwrap(); + let lax = Command::new(binary()) + .args(["decrypt", "--lax-key-search", "--keys-file"]) + .arg(&mixed_store) + .arg(&encrypted) + .output() + .unwrap(); + assert!( + lax.status.success(), + "{}", + String::from_utf8_lossy(&lax.stderr) + ); + assert_eq!(lax.stdout, fs::read(&plaintext).unwrap()); +} + +#[test] +fn key_store_rsa_recipient_round_trips_with_explicit_private_key() { + // A named RSAKeyValue in the imported store must serve an EncryptedKey + // recipient without an additional public-key file option. + let temp = tempfile::tempdir().unwrap(); + let template = temp.path().join("template.xml"); + let store = temp.path().join("keys.xml"); + let plaintext = temp.path().join("plaintext.bin"); + let encrypted = temp.path().join("encrypted.xml"); + let public_pem = + fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem")) + .unwrap(); + let public = RsaPublicKey::from_public_key_pem(&public_pem).unwrap(); + let base64 = base64::engine::general_purpose::STANDARD; + fs::write( + &store, + format!( + "recipient{}{}", + base64.encode(public.n().to_be_bytes_trimmed_vartime()), + base64.encode(public.e().to_be_bytes_trimmed_vartime()), + ), + ) + .unwrap(); + fs::write( + &template, + r#"recipient"#, + ) + .unwrap(); + fs::write(&plaintext, b"named RSA recipient payload").unwrap(); + let encrypt = Command::new(binary()) + .args(["encrypt", "--keys-file"]) + .arg(&store) + .arg("--binary-data") + .arg(&plaintext) + .arg("--output") + .arg(&encrypted) + .arg(&template) + .output() + .unwrap(); + assert!( + encrypt.status.success(), + "{}", + String::from_utf8_lossy(&encrypt.stderr) + ); + let decrypt = Command::new(binary()) + .args(["decrypt", "--privkey-pem"]) + .arg(project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem")) + .arg(&encrypted) + .output() + .unwrap(); + assert!( + decrypt.status.success(), + "{}", + String::from_utf8_lossy(&decrypt.stderr) + ); + assert_eq!(decrypt.stdout, fs::read(&plaintext).unwrap()); + + // Lax lookup must still prefer the recipient's exact name over an earlier + // usable-but-wrong RSA key in the same store. + let wrong_public_pem = + fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem")) + .unwrap(); + let wrong_public = RsaPublicKey::from_public_key_pem(&wrong_public_pem).unwrap(); + fs::write( + &store, + format!( + "wrong{}{}recipient{}{}", + base64.encode(wrong_public.n().to_be_bytes_trimmed_vartime()), + base64.encode(wrong_public.e().to_be_bytes_trimmed_vartime()), + base64.encode(public.n().to_be_bytes_trimmed_vartime()), + base64.encode(public.e().to_be_bytes_trimmed_vartime()), + ), + ) + .unwrap(); + let lax_encrypted = temp.path().join("lax-encrypted.xml"); + let lax_encrypt = Command::new(binary()) + .args(["encrypt", "--lax-key-search", "--keys-file"]) + .arg(&store) + .arg("--binary-data") + .arg(&plaintext) + .arg("--output") + .arg(&lax_encrypted) + .arg(&template) + .output() + .unwrap(); + assert!( + lax_encrypt.status.success(), + "{}", + String::from_utf8_lossy(&lax_encrypt.stderr) + ); + let lax_decrypt = Command::new(binary()) + .args(["decrypt", "--privkey-pem"]) + .arg(project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem")) + .arg(&lax_encrypted) + .output() + .unwrap(); + assert!( + lax_decrypt.status.success(), + "{}", + String::from_utf8_lossy(&lax_decrypt.stderr) + ); + assert_eq!(lax_decrypt.stdout, fs::read(&plaintext).unwrap()); + + let unsupported_store_decrypt = Command::new(binary()) + .args(["decrypt", "--keys-file"]) + .arg(&store) + .arg(&encrypted) + .output() + .unwrap(); + assert!(!unsupported_store_decrypt.status.success()); + assert!( + String::from_utf8_lossy(&unsupported_store_decrypt.stderr) + .contains("--keys-file does not supply RSA recipient private keys") + ); + + let wrong_public_pem = + fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem")) + .unwrap(); + let wrong_public = RsaPublicKey::from_public_key_pem(&wrong_public_pem).unwrap(); + let conflicting = format!( + "recipient{}{}", + base64.encode(wrong_public.n().to_be_bytes_trimmed_vartime()), + base64.encode(wrong_public.e().to_be_bytes_trimmed_vartime()), + ); + fs::write(&template, conflicting).unwrap(); + let rejected = Command::new(binary()) + .args(["encrypt", "--keys-file"]) + .arg(&store) + .arg("--binary-data") + .arg(&plaintext) + .arg(&template) + .output() + .unwrap(); + assert!(!rejected.status.success()); +} + +#[test] +fn lax_store_encryption_replaces_stale_content_key_name() { + // Lax fallback must publish the selected content-key identity so a strict + // decryptor can select that same key from the resulting document. + let temp = tempfile::tempdir().unwrap(); + let template = temp.path().join("template.xml"); + let store = temp.path().join("keys.xml"); + let plaintext = temp.path().join("plaintext.bin"); + let encrypted = temp.path().join("encrypted.xml"); + fs::write( + &template, + r#"stale"#, + ) + .unwrap(); + let encoded = base64::engine::general_purpose::STANDARD.encode(b"0123456789abcdef"); + fs::write( + &store, + format!("selected{encoded}"), + ) + .unwrap(); + fs::write(&plaintext, b"lax content key fallback").unwrap(); + let encrypt = Command::new(binary()) + .args(["encrypt", "--lax-key-search", "--keys-file"]) + .arg(&store) + .arg("--binary-data") + .arg(&plaintext) + .arg("--output") + .arg(&encrypted) + .arg(&template) + .output() + .unwrap(); + assert!( + encrypt.status.success(), + "{}", + String::from_utf8_lossy(&encrypt.stderr) + ); + let xml = fs::read_to_string(&encrypted).unwrap(); + let document = roxmltree::Document::parse(&xml).unwrap(); + let content_key_name = document + .root_element() + .children() + .find(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyInfo"))) + .and_then(|key_info| { + key_info + .children() + .find(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyName"))) + }) + .and_then(|node| node.text()); + assert_eq!(content_key_name, Some("selected")); + let decrypt = Command::new(binary()) + .args(["decrypt", "--keys-file"]) + .arg(&store) + .arg(&encrypted) + .output() + .unwrap(); + assert!( + decrypt.status.success(), + "{}", + String::from_utf8_lossy(&decrypt.stderr) + ); + assert_eq!(decrypt.stdout, b"lax content key fallback"); +} + +#[test] +fn lax_rsa_recipients_charge_only_attempted_store_keys() { + // Two exact recipients must not each consume the 33 unused lax fallbacks. + let temp = tempfile::tempdir().unwrap(); + let template = temp.path().join("template.xml"); + let store = temp.path().join("keys.xml"); + let plaintext = temp.path().join("plaintext.bin"); + let encrypted = temp.path().join("encrypted.xml"); + let public_pem = + fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-4096-pubkey.pem")) + .unwrap(); + let public = RsaPublicKey::from_public_key_pem(&public_pem).unwrap(); + let base64 = base64::engine::general_purpose::STANDARD; + let modulus = base64.encode(public.n().to_be_bytes_trimmed_vartime()); + let exponent = base64.encode(public.e().to_be_bytes_trimmed_vartime()); + let mut key_store = String::from(""); + for index in 0..33 { + key_store.push_str(&format!( + "recipient-{index}{modulus}{exponent}" + )); + } + key_store.push_str(""); + fs::write(&store, key_store).unwrap(); + let mut recipient_nodes = String::new(); + for index in 0..2 { + recipient_nodes.push_str(&format!( + "recipient-{index}" + )); + } + fs::write( + &template, + format!( + "{recipient_nodes}" + ), + ) + .unwrap(); + fs::write(&plaintext, b"two named recipients").unwrap(); + let encrypt = Command::new(binary()) + .args(["encrypt", "--lax-key-search", "--keys-file"]) + .arg(&store) + .arg("--binary-data") + .arg(&plaintext) + .arg("--output") + .arg(&encrypted) + .arg(&template) + .output() + .unwrap(); + assert!( + encrypt.status.success(), + "{}", + String::from_utf8_lossy(&encrypt.stderr) + ); + let decrypt = Command::new(binary()) + .args(["decrypt", "--privkey-pem"]) + .arg(project_root().join("tests/fixtures/keys/rsa/rsa-4096-key.pem")) + .arg(&encrypted) + .output() + .unwrap(); + assert!(decrypt.status.success()); + assert_eq!(decrypt.stdout, b"two named recipients"); +} + #[test] fn encryption_writes_requested_diagnostics_and_rejects_duplicate_methods() { // Encryption diagnostics are a separate stdout contract, and malformed From 0ecfef1552da43f5d6d3f3e43a952cafefed6cdb Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Wed, 30 Sep 2026 20:17:46 +0300 Subject: [PATCH 2/6] fix(cli): consume lax store recipient candidates --- docs/key-management.md | 4 + tools/xmlsec1/src/commands.rs | 18 +++- tools/xmlsec1/tests/process_contract.rs | 106 ++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 4 deletions(-) diff --git a/docs/key-management.md b/docs/key-management.md index 0f1e0ed6..cf4bb574 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -102,6 +102,10 @@ 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. 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 diff --git a/tools/xmlsec1/src/commands.rs b/tools/xmlsec1/src/commands.rs index 815237cb..75e10de7 100644 --- a/tools/xmlsec1/src/commands.rs +++ b/tools/xmlsec1/src/commands.rs @@ -2211,7 +2211,7 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman )?; let mut store_candidate_budget = KeyCandidateBudget::with_limit(policy.resources.max_key_candidates); - let mut public_keys_by_name = HashMap::new(); + 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 @@ -2221,13 +2221,17 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman { public_key_count += 1; only_public_key = Some(entry); - public_keys_by_name.insert(entry.name.as_str(), entry); + available_public_keys_by_name.insert(entry.name.as_str(), entry); } for (recipient, metadata) in template_recipients.into_iter().zip(recipient_metadata) { let lax = invocation.flag("lax-key-search"); let exact = match recipient.key_name.as_deref() { - Some(name) => public_keys_by_name.get(name).copied(), - None if public_key_count == 1 => only_public_key, + Some(name) => available_public_keys_by_name.get(name).copied(), + None if public_key_count == 1 => only_public_key.and_then(|entry| { + available_public_keys_by_name + .get(entry.name.as_str()) + .copied() + }), None if !lax && public_key_count > 1 => { return Err(CommandError::Usage( "multiple matching keys in --keys-file".into(), @@ -2240,6 +2244,7 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman } let fallbacks = store.public_keys().iter().filter(|entry| { lax && entry.usages.allows(key_manager::KeyUsage::Encrypt) + && available_public_keys_by_name.contains_key(entry.name.as_str()) && !exact.is_some_and(|selected| std::ptr::eq(selected, *entry)) }); let mut selected = None; @@ -2274,6 +2279,11 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman 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 { diff --git a/tools/xmlsec1/tests/process_contract.rs b/tools/xmlsec1/tests/process_contract.rs index 55de1eec..7cac0048 100644 --- a/tools/xmlsec1/tests/process_contract.rs +++ b/tools/xmlsec1/tests/process_contract.rs @@ -3792,6 +3792,112 @@ fn lax_store_encryption_replaces_stale_content_key_name() { assert_eq!(decrypt.stdout, b"lax content key fallback"); } +#[test] +fn lax_store_rsa_recipients_consume_distinct_candidates() { + // Lax selection consumes each entry once, including exact and singleton + // matches; every recipient must decrypt and exhaustion must emit no output. + let temp = tempfile::tempdir().unwrap(); + let store = temp.path().join("keys.xml"); + let template = temp.path().join("template.xml"); + let plaintext = temp.path().join("plaintext.bin"); + let encrypted = temp.path().join("encrypted.xml"); + let base64 = base64::engine::general_purpose::STANDARD; + let mut entries = Vec::new(); + for (name, bits) in [("a", 2048), ("b", 4096)] { + let pem = fs::read_to_string( + project_root().join(format!("tests/fixtures/keys/rsa/rsa-{bits}-pubkey.pem")), + ) + .unwrap(); + let public = RsaPublicKey::from_public_key_pem(&pem).unwrap(); + entries.push(format!( + "{name}{}{}", + base64.encode(public.n().to_be_bytes_trimmed_vartime()), + base64.encode(public.e().to_be_bytes_trimmed_vartime()), + )); + } + fs::write( + &store, + format!( + "{}", + entries.join("") + ), + ) + .unwrap(); + fs::write(&plaintext, b"distinct store recipients").unwrap(); + let write_template = |names: &[Option<&str>]| { + let recipients = names.iter().map(|name| { + let key_info = name.map_or_else(String::new, |name| format!("{name}")); + format!("{key_info}") + }).collect::(); + fs::write(&template, format!("{recipients}")).unwrap(); + }; + let encrypt = |output: &Path| { + Command::new(binary()) + .args(["encrypt", "--lax-key-search", "--keys-file"]) + .arg(&store) + .arg("--binary-data") + .arg(&plaintext) + .arg("--output") + .arg(output) + .arg(&template) + .output() + .unwrap() + }; + for names in [ + [None, None], + [Some("unknown-a"), Some("unknown-b")], + [Some("a"), None], + ] { + write_template(&names); + let result = encrypt(&encrypted); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + 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"); + } + } + 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. From 1caee590a4135ca8b4bd2ccbb731024d85b9b524 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 1 Oct 2026 10:09:00 +0300 Subject: [PATCH 3/6] fix(keys): enforce import and selection gates Reserve later named RSA recipients before lax fallback, enforce protected PKCS#8 KDF limits, validate stored DSA and policy snapshots, and bound DSA SPKI decoding across verification paths. Compare complete content-key identities and cover the failure paths with regression tests. --- docs/key-management.md | 7 ++ src/key_manager.rs | 59 +++++++++++ src/provider.rs | 3 +- src/xmldsig/keys.rs | 63 ++++------- src/xmldsig/signature.rs | 43 ++++++-- tools/xmlsec1/src/commands.rs | 133 +++++++++++++++++++++--- tools/xmlsec1/src/key_material.rs | 4 + tools/xmlsec1/tests/process_contract.rs | 14 ++- 8 files changed, 263 insertions(+), 63 deletions(-) diff --git a/docs/key-management.md b/docs/key-management.md index cf4bb574..dc1e172c 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -59,6 +59,9 @@ inventory name lookup. The operation policy is required so source sizes are checked before RSA decoding. Direct and XML key-store imports accept only 16-, 24-, or 32-byte AES keys; unsupported public-key algorithms are rejected at import rather than acquiring verification permission. +Public DSA entries must contain independently usable parameters; the inventory +does not infer missing parameters from another entry. Verification validates the +complete policy snapshot before selecting or copying any key, including HMAC. `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. @@ -73,6 +76,8 @@ resource-policy error without invoking the callback. are capped by implementation safety ceilings and checked before decryption. Exceeding a recognized KDF's work or memory limit returns a policy error; missing or incorrect passwords remain protected-container errors. +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. @@ -106,6 +111,8 @@ For multiple RSA encryption recipients, `--lax-key-search` prefers an exact name and then tries remaining compatible entries in store order. Each selected entry is consumed once for that operation; insufficient entries fail before any encrypted output is written. +Entries explicitly named by later recipient slots are reserved before assigning +fallbacks, so an unnamed slot cannot consume a later recipient's requested 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 diff --git a/src/key_manager.rs b/src/key_manager.rs index 12e53b27..588f3530 100644 --- a/src/key_manager.rs +++ b/src/key_manager.rs @@ -338,6 +338,7 @@ impl<'a> KeyResolver for InventoryVerificationResolver<'a> { 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)?; } @@ -1648,6 +1649,10 @@ impl KeyInventory { }); } 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)); @@ -3404,6 +3409,36 @@ mod tests { ); } + #[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, @@ -4377,6 +4412,30 @@ mod tests { 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 diff --git a/src/provider.rs b/src/provider.rs index 4e4bae29..4f9d68a0 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -908,7 +908,8 @@ mod rustcrypto_x509 { // Certificate signatures are ASN.1 DER integers sized by the // issuer's q parameter. XMLDSig's fixed 20-byte r||s framing // applies only to SignatureValue, never to X.509 signatures. - let Ok(key) = dsa::VerifyingKey::from_public_key_der(issuer_spki_der) else { + let Ok(key) = crate::xmldsig::signature::decode_dsa_verifying_key(issuer_spki_der) + else { return Ok(false); }; let Ok(signature) = dsa::Signature::from_der(signature) else { diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 5c1bc664..622f578e 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -3,9 +3,9 @@ use std::{collections::HashMap, fmt, time::SystemTime}; use crypto_bigint::BoxedUint; -use der::Decode as _; -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, @@ -14,10 +14,11 @@ 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, - verify_dsa_signature_spki_primitive, verify_dsa_signature_spki_with_minimum, - verify_rsa_signature_spki_primitive, verify_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_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, }; use super::{ DsigError, KeyInfo, KeyInfoSource, KeyResolver, KeyValueInfo, SignatureAlgorithm, VerifyingKey, @@ -1402,8 +1403,8 @@ fn validate_spki_algorithm( .map(|oid| oid.to_id_string()); match (algorithm, parsed) { (SignatureAlgorithm::DsaSha1 | SignatureAlgorithm::DsaSha256, PublicKey::DSA(_)) => { - let _ = dsa::VerifyingKey::from_public_key_der(public_key_bytes) - .map_err(|_| KeyResolutionError::AlgorithmMismatch)?; + let _ = decode_dsa_verifying_key(public_key_bytes) + .map_err(|_| KeyResolutionError::InvalidPublicKey)?; Ok(()) } ( @@ -1448,9 +1449,8 @@ pub(crate) fn supported_parsed_spki_is_rsa( Ok(true) } PublicKey::DSA(_) => { - preflight_dsa_spki(public_key_bytes)?; - 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(false) } PublicKey::EC(_) => { @@ -1467,36 +1467,6 @@ pub(crate) fn supported_parsed_spki_is_rsa( } } -#[derive(der::Sequence)] -struct BorrowedDsaPublicParameters<'a> { - p: der::asn1::UintRef<'a>, - q: der::asn1::UintRef<'a>, - g: der::asn1::UintRef<'a>, -} - -fn preflight_dsa_spki(der: &[u8]) -> Result<(), KeyResolutionError> { - let spki = rsa::pkcs8::SubjectPublicKeyInfoRef::from_der(der) - .map_err(|_| KeyResolutionError::InvalidPublicKey)?; - let parameters = spki - .algorithm - .parameters - .as_ref() - .ok_or(KeyResolutionError::InvalidPublicKey)? - .decode_as::>() - .map_err(|_| KeyResolutionError::InvalidPublicKey)?; - let y = der::asn1::UintRef::from_der(spki.subject_public_key.raw_bytes()) - .map_err(|_| KeyResolutionError::InvalidPublicKey)?; - if [parameters.p, parameters.q, parameters.g, y] - .into_iter() - .any(|component| { - component.as_bytes().len() > crate::hard_limits::DSA_KEY_COMPONENT_BYTE_CEILING - }) - { - return Err(KeyResolutionError::InvalidPublicKey); - } - Ok(()) -} - 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(|_| ()), @@ -1541,6 +1511,7 @@ mod tests { 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, @@ -1565,7 +1536,7 @@ mod tests { // 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(&BorrowedDsaPublicParameters { + 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"), @@ -1582,7 +1553,13 @@ mod tests { }; let encoded = der::Encode::to_der(&spki).expect("SPKI encodes"); assert!(matches!( - preflight_dsa_spki(&encoded), + 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) )); } diff --git a/src/xmldsig/signature.rs b/src/xmldsig/signature.rs index d0807f10..13b71a02 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); diff --git a/tools/xmlsec1/src/commands.rs b/tools/xmlsec1/src/commands.rs index 75e10de7..5a897b67 100644 --- a/tools/xmlsec1/src/commands.rs +++ b/tools/xmlsec1/src/commands.rs @@ -1107,13 +1107,36 @@ fn prepare_signing_key_candidate( split_key_and_certificates(option.value.as_deref().unwrap_or_default())?; let key_bytes = key_material::read(path)?; material_budget.charge(key_bytes.len())?; - let key = key_material::decode_signing_key( - Path::new(path), - &key_bytes, - private_key_format(option), - algorithm, - password, - )?; + let format = private_key_format(option); + let key = if key_material::is_encrypted_pkcs8_container(&key_bytes, format) { + // All protected PKCS#8 aliases share the inventory's pre-decryption KDF gate; + // selecting a CLI spelling must never change import policy enforcement. + let mut inventory = KeyInventory::default(); + let name = option.parameter.as_deref().unwrap_or("explicit"); + match format { + key_material::PrivateKeyFormat::Pem | key_material::PrivateKeyFormat::Pkcs8Pem => { + inventory.add_private_pem( + name.into(), + &key_bytes, + password, + key_manager::KeyUsages::SIGN, + &policy.resources, + )?; + } + key_material::PrivateKeyFormat::Der | key_material::PrivateKeyFormat::Pkcs8Der => { + inventory.add_private_der( + name.into(), + &key_bytes, + password, + key_manager::KeyUsages::SIGN, + &policy.resources, + )?; + } + } + inventory.signing_key(name, algorithm, policy)? + } else { + key_material::decode_signing_key(Path::new(path), &key_bytes, format, algorithm, password)? + }; validate_signing_key(key.as_ref(), algorithm, policy) .map_err(|error| CommandError::Signature(error.to_string()))?; let (certificate_writer, leaf_certificate_der) = if certificate_paths.is_empty() { @@ -2221,16 +2244,33 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman { public_key_count += 1; only_public_key = Some(entry); - available_public_keys_by_name.insert(entry.name.as_str(), entry); + available_public_keys_by_name.insert(entry.name.as_str(), (entry, 0_usize)); + } + // Reserve exact names before fallback assignment. Reuse the availability + // index so reservations require neither key copies nor a second map. + for recipient in &template_recipients { + if let Some(name) = recipient.key_name.as_deref() + && let Some((_, remaining)) = available_public_keys_by_name.get_mut(name) + { + *remaining += 1; + } } for (recipient, metadata) in template_recipients.into_iter().zip(recipient_metadata) { let lax = invocation.flag("lax-key-search"); + if let Some(name) = recipient.key_name.as_deref() + && let Some((_, remaining)) = available_public_keys_by_name.get_mut(name) + { + *remaining -= 1; + } let exact = match recipient.key_name.as_deref() { - Some(name) => available_public_keys_by_name.get(name).copied(), + Some(name) => available_public_keys_by_name + .get(name) + .map(|(entry, _)| *entry), None if public_key_count == 1 => only_public_key.and_then(|entry| { available_public_keys_by_name .get(entry.name.as_str()) - .copied() + .filter(|(_, remaining)| !lax || *remaining == 0) + .map(|(entry, _)| *entry) }), None if !lax && public_key_count > 1 => { return Err(CommandError::Usage( @@ -2244,7 +2284,9 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman } let fallbacks = store.public_keys().iter().filter(|entry| { lax && entry.usages.allows(key_manager::KeyUsage::Encrypt) - && available_public_keys_by_name.contains_key(entry.name.as_str()) + && available_public_keys_by_name + .get(entry.name.as_str()) + .is_some_and(|(_, remaining)| *remaining == 0) && !exact.is_some_and(|selected| std::ptr::eq(selected, *entry)) }); let mut selected = None; @@ -2930,7 +2972,7 @@ fn apply_encryption_template( 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"), - ) && template_name.text() != generated_name.text() + ) && !same_direct_simple_text(template_name, generated_name, "KeyName")? { replacements.push(replace_element_text( template, @@ -4101,6 +4143,73 @@ mod tests { Invocation::parse(arguments.iter().map(OsString::from)).unwrap() } + #[test] + fn explicit_pkcs8_signing_enforces_import_kdf_limits() { + // Explicit PEM/DER options, including generic private-key aliases, must + // reject KDF policy violations before password-dependent decryption. + use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng as _}; + use rsa::pkcs8::{DecodePrivateKey as _, EncodePrivateKey as _}; + let rsa = rsa::RsaPrivateKey::from_pkcs8_pem(include_str!( + "../../../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .unwrap(); + let plain = rsa.to_pkcs8_der().unwrap(); + let encrypted = rsa::pkcs8::PrivateKeyInfoRef::try_from(plain.as_bytes()) + .unwrap() + .encrypt_with_rng(&mut ChaCha20Rng::seed_from_u64(42), b"correct") + .unwrap(); + let pem = encrypted + .to_pem("ENCRYPTED PRIVATE KEY", der::pem::LineEnding::LF) + .unwrap(); + let temp = tempfile::tempdir().unwrap(); + for option_name in ["pkcs8-pem", "pkcs8-der", "privkey-pem", "privkey-der"] { + let path = temp.path().join(option_name); + fs::write( + &path, + if option_name.ends_with("pem") { + pem.as_bytes() + } else { + encrypted.as_bytes() + }, + ) + .unwrap(); + let parsed = Invocation::parse([ + OsString::from("xmlsec1"), + OsString::from("sign"), + OsString::from(format!("--{option_name}")), + path.into_os_string(), + OsString::from("template.xml"), + ]) + .unwrap(); + for memory_limit in [false, true] { + let mut policy = SigningPolicy::default(); + if memory_limit { + policy.resources.max_key_import_kdf_memory_bytes = 1; + } else { + policy.resources.max_key_import_kdf_work = 1; + } + let mut budget = + ExternalMaterialBudget::new(policy.resources.max_external_resource_total_bytes); + let result = prepare_signing_key_candidate( + parsed.values(option_name).next().unwrap(), + SignatureAlgorithm::RsaSha256, + &policy, + Some(b"wrong"), + &mut budget, + ); + assert!( + matches!( + result, + Err(CommandError::KeyStore(key_manager::KeyStoreError::Policy( + _ + ))) + ), + "{option_name}, memory limit {memory_limit}" + ); + } + } + } + #[test] fn lax_key_search_stops_on_password_and_policy_failures() { // Candidate search may skip incompatible keys, never terminal diff --git a/tools/xmlsec1/src/key_material.rs b/tools/xmlsec1/src/key_material.rs index e04e56ef..d5092f99 100644 --- a/tools/xmlsec1/src/key_material.rs +++ b/tools/xmlsec1/src/key_material.rs @@ -396,6 +396,10 @@ struct TraditionalDsaPrivateKey<'a> { x: UintRef<'a>, } +pub(crate) fn is_encrypted_pkcs8_container(bytes: &[u8], format: PrivateKeyFormat) -> bool { + pkcs8_container_kind(bytes, format) == Some(Pkcs8ContainerKind::Encrypted) +} + fn pkcs8_container_kind(bytes: &[u8], format: PrivateKeyFormat) -> Option { match format { PrivateKeyFormat::Pem | PrivateKeyFormat::Pkcs8Pem => { diff --git a/tools/xmlsec1/tests/process_contract.rs b/tools/xmlsec1/tests/process_contract.rs index 7cac0048..01692769 100644 --- a/tools/xmlsec1/tests/process_contract.rs +++ b/tools/xmlsec1/tests/process_contract.rs @@ -3740,7 +3740,7 @@ fn lax_store_encryption_replaces_stale_content_key_name() { let encrypted = temp.path().join("encrypted.xml"); fs::write( &template, - r#"stale"#, + r#"selected-old"#, ) .unwrap(); let encoded = base64::engine::general_purpose::STANDARD.encode(b"0123456789abcdef"); @@ -3847,6 +3847,7 @@ fn lax_store_rsa_recipients_consume_distinct_candidates() { [None, None], [Some("unknown-a"), Some("unknown-b")], [Some("a"), None], + [None, Some("a")], ] { write_template(&names); let result = encrypt(&encrypted); @@ -3855,6 +3856,17 @@ fn lax_store_rsa_recipients_consume_distinct_candidates() { "{}", 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"]) From 066b150d3c50ebd406d2c709e2d220e5e6a8f6c7 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 1 Oct 2026 12:31:45 +0300 Subject: [PATCH 4/6] fix(keys): preserve selection invariants --- docs/key-management.md | 9 +- src/key_manager.rs | 386 ++++++++++++++++++++++-- src/xmldsig/keys.rs | 14 +- src/xmldsig/signature.rs | 5 +- tools/xmlsec1/src/commands.rs | 135 +++++++-- tools/xmlsec1/tests/process_contract.rs | 38 +++ 6 files changed, 524 insertions(+), 63 deletions(-) diff --git a/docs/key-management.md b/docs/key-management.md index dc1e172c..9e9747f8 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -62,6 +62,11 @@ at import rather than acquiring verification permission. Public DSA entries must contain independently usable parameters; the inventory does not infer missing parameters from another entry. Verification validates the complete policy snapshot before selecting or copying any key, including HMAC. +EC SPKI and certificate imports use the verifier's uncompressed SEC1 profile; +compressed points are rejected before granting verification usage. Each complete +KeyValue is one resource for selection limits, not one resource per component. +When a named certificate is selected, enabled CRL checking retains both inventory +and document CRLs, with their combined resource budget checked before copying. `add_private_der_with_password_callback` asks the caller for a zeroizing byte password only for encrypted PKCS#8; plaintext input does not invoke it. @@ -112,7 +117,9 @@ name and then tries remaining compatible entries in store order. Each selected entry is consumed once for that operation; insufficient entries fail before any encrypted output is written. Entries explicitly named by later recipient slots are reserved before assigning -fallbacks, so an unnamed slot cannot consume a later recipient's requested key. +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. 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 diff --git a/src/key_manager.rs b/src/key_manager.rs index 588f3530..dd923f39 100644 --- a/src/key_manager.rs +++ b/src/key_manager.rs @@ -79,26 +79,35 @@ fn check_selected_public_material( Ok(()) }; match source { - KeyInfoSource::KeyValue(KeyValueInfo::Rsa { modulus, exponent }) => { - charge(modulus.len())?; - charge(exponent.len())?; - } - KeyInfoSource::KeyValue(KeyValueInfo::Dsa { p, q, g, y }) => { - for length in [ - 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(), - ] { - charge(length)?; - } - } - KeyInfoSource::KeyValue(KeyValueInfo::Ec { - curve_oid, - public_key, - }) => { - charge(curve_oid.len())?; - charge(public_key.len())?; + 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) => { @@ -482,13 +491,30 @@ impl<'a> KeyResolver for InventoryVerificationResolver<'a> { .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) { + 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, @@ -511,11 +537,7 @@ impl<'a> KeyResolver for InventoryVerificationResolver<'a> { DefaultKeyResolver::new(KeyResolverConfig { lookup_certs: self.inventory.lookup_certificates.clone(), trusted_certs: self.inventory.trusted_certificates.clone(), - crls: if policy.key_trust.check_crls && policy.key_trust.verify_x509_chains { - self.inventory.crls.clone() - } else { - Vec::new() - }, + crls: crls.cloned().collect(), ..KeyResolverConfig::default() }) } else { @@ -3823,6 +3845,123 @@ mod tests { assert!(inventory.public_keys.is_empty()); } + #[test] + fn named_certificate_resolution_preserves_document_crl() { + // Named inventory selection must preserve document revocation evidence; + // without it the same chain is valid and resolves successfully. + use rcgen::{CertificateParams, KeyPair, KeyUsagePurpose, SerialNumber}; + let mut root_params = CertificateParams::new(Vec::new()).expect("root params"); + root_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + root_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let root = rcgen::CertifiedIssuer::self_signed( + root_params, + KeyPair::generate().expect("root key"), + ) + .expect("root certificate"); + let mut leaf_params = CertificateParams::new(Vec::new()).expect("leaf params"); + leaf_params.serial_number = Some(SerialNumber::from(42_u64)); + leaf_params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + let leaf = leaf_params + .signed_by(&KeyPair::generate().expect("leaf key"), &root) + .expect("leaf certificate"); + let now = time::OffsetDateTime::now_utc(); + let crl = rcgen::CertificateRevocationListParams { + this_update: now - time::Duration::days(1), + next_update: now + time::Duration::days(1), + crl_number: SerialNumber::from(1_u64), + issuing_distribution_point: None, + revoked_certs: vec![rcgen::RevokedCertParams { + serial_number: SerialNumber::from(42_u64), + revocation_time: now - time::Duration::hours(1), + reason_code: None, + invalidity_date: None, + }], + key_identifier_method: rcgen::KeyIdMethod::Sha256, + } + .signed_by(&root) + .expect("signed CRL"); + let resources = ResourcePolicy::default(); + let mut inventory = KeyInventory::default(); + inventory + .add_public_der("leaf".into(), leaf.der().to_vec(), &resources) + .expect("leaf imports"); + inventory + .add_certificate_der(root.der().to_vec(), true, &resources) + .expect("root imports"); + let policy = crate::policy::VerificationPolicy { + key_trust: crate::policy::KeyTrustPolicy { + verify_x509_chains: true, + check_crls: true, + verification_time: Some(std::time::SystemTime::now()), + ..crate::policy::KeyTrustPolicy::default() + }, + ..crate::policy::VerificationPolicy::default() + }; + let mut info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("leaf".into())], + }; + let resolver = inventory.verification_resolver(); + assert!( + resolver + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::EcdsaSha256, + &policy, + crate::provider::default_provider() + ) + .expect("unrevoked chain resolves") + .is_some() + ); + info.sources.push(KeyInfoSource::X509Data(X509DataInfo { + crls: vec![crl.der().to_vec()], + ..X509DataInfo::default() + })); + let error = resolver + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::EcdsaSha256, + &policy, + crate::provider::default_provider(), + ) + .err() + .expect("document CRL revokes leaf"); + assert!( + error + .to_string() + .contains("certificate at chain position 0 is revoked"), + "{error}" + ); + let mut bounded = policy.clone(); + bounded.resources.max_external_resource_total_bytes = + leaf.der().len() + root.der().len() + crl.der().len() - 1; + assert!(matches!( + resolver.resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::EcdsaSha256, + &bounded, + crate::provider::default_provider() + ), + Err(DsigError::Policy( + crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + .. + } + )) + )); + bounded.key_trust.check_crls = false; + assert!( + resolver + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::EcdsaSha256, + &bounded, + crate::provider::default_provider() + ) + .expect("disabled CRL checks do not load CRLs") + .is_some() + ); + } + #[test] fn named_certificate_resolution_enforces_inventory_crl() { // A KeyName must not drop caller-supplied revocation evidence. @@ -4053,6 +4192,101 @@ mod tests { )); } + #[test] + fn selected_key_value_is_one_resource() { + // Representation must not split one key into separately bounded + // components; exact boundaries remain accepted for all KeyValue kinds. + for value in [ + KeyValueInfo::Rsa { + modulus: vec![1; 256], + exponent: vec![1; 3], + }, + KeyValueInfo::Dsa { + p: Some(vec![1; 64]), + q: Some(vec![1; 16]), + g: Some(vec![1; 64]), + y: vec![1; 64], + }, + KeyValueInfo::Ec { + curve_oid: "1.2.840.10045.3.1.7".into(), + public_key: vec![1; 65], + }, + ] { + let info = KeyInfo { + sources: vec![KeyInfoSource::KeyValue(value)], + }; + let size = + check_selected_public_material(&info, &ResourcePolicy::default()).expect("size"); + let resources = ResourcePolicy { + max_external_resource_bytes: size, + ..ResourcePolicy::default() + }; + assert_eq!( + check_selected_public_material(&info, &resources).expect("exact limit"), + size + ); + let resources = ResourcePolicy { + max_external_resource_bytes: size - 1, + ..resources + }; + assert!(matches!(check_selected_public_material(&info, &resources), + Err(DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, actual, .. + })) if actual == size)); + } + } + + #[test] + fn named_verification_bounds_complete_key_value() { + // A broadly imported XML key must obey the tighter operation snapshot + // before the resolver constructs an SPKI from its components. + use rsa::{pkcs8::DecodePublicKey as _, traits::PublicKeyParts as _}; + let public = RsaPublicKey::from_public_key_pem(include_str!( + "../tests/fixtures/keys/rsa/rsa-2048-pubkey.pem" + )) + .expect("RSA fixture"); + let modulus = public.n().to_be_bytes_trimmed_vartime(); + let exponent = public.e().to_be_bytes_trimmed_vartime(); + let size = modulus.len() + exponent.len(); + let base64 = base64::engine::general_purpose::STANDARD; + let xml = format!( + "named{}{}", + base64.encode(modulus), + base64.encode(exponent) + ); + let inventory = KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(ResourcePolicy::default()), + XmlBackend::default(), + ) + .expect("broad import"); + let info = KeyInfo { + sources: vec![KeyInfoSource::KeyName("named".into())], + }; + let mut policy = crate::policy::VerificationPolicy::default(); + policy.resources.max_external_resource_bytes = size; + assert!( + inventory + .verification_resolver() + .resolve_with_policy_and_provider( + Some(&info), + SignatureAlgorithm::RsaSha256, + &policy, + crate::provider::default_provider() + ) + .expect("exact complete-key limit") + .is_some() + ); + policy.resources.max_external_resource_bytes = size - 1; + assert!( + matches!(inventory.verification_resolver().resolve_with_policy_and_provider(Some(&info), SignatureAlgorithm::RsaSha256, + &policy, crate::provider::default_provider()), + Err(DsigError::Policy(crate::policy::PolicyViolation::ResourceLimit { + resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, actual, .. + })) if actual == size) + ); + } + #[test] fn selected_certificate_and_anchors_share_aggregate_budget() { // Selected named material and configured trust material are one @@ -4493,6 +4727,106 @@ mod tests { 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. diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 622f578e..69288094 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -16,9 +16,9 @@ use zeroize::Zeroizing; use super::signature::{ decode_dsa_verifying_key, signature_value_matches_spki, signature_value_matches_spki_with_encoding, validate_dsa_signature_spki_with_minimum, - 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, + 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, }; use super::{ DsigError, KeyInfo, KeyInfoSource, KeyResolver, KeyValueInfo, SignatureAlgorithm, VerifyingKey, @@ -1421,12 +1421,14 @@ 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(()) } @@ -1453,7 +1455,9 @@ pub(crate) fn supported_parsed_spki_is_rsa( .map_err(|_| KeyResolutionError::InvalidPublicKey)?; Ok(false) } - PublicKey::EC(_) => { + 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 diff --git a/src/xmldsig/signature.rs b/src/xmldsig/signature.rs index 13b71a02..eb28afa3 100644 --- a/src/xmldsig/signature.rs +++ b/src/xmldsig/signature.rs @@ -985,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> { @@ -995,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/tools/xmlsec1/src/commands.rs b/tools/xmlsec1/src/commands.rs index 5a897b67..b280e1b0 100644 --- a/tools/xmlsec1/src/commands.rs +++ b/tools/xmlsec1/src/commands.rs @@ -2244,33 +2244,82 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman { public_key_count += 1; only_public_key = Some(entry); - available_public_keys_by_name.insert(entry.name.as_str(), (entry, 0_usize)); + available_public_keys_by_name.insert( + entry.name.as_str(), + AvailableStoreRecipient { + entry, + reservations: 0, + loaded: None, + }, + ); } - // Reserve exact names before fallback assignment. Reuse the availability - // index so reservations require neither key copies nor a second map. - for recipient in &template_recipients { - if let Some(name) = recipient.key_name.as_deref() - && let Some((_, remaining)) = available_public_keys_by_name.get_mut(name) - { - *remaining += 1; + 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 (recipient, metadata) in template_recipients.into_iter().zip(recipient_metadata) { - let lax = invocation.flag("lax-key-search"); - if let Some(name) = recipient.key_name.as_deref() - && let Some((_, remaining)) = available_public_keys_by_name.get_mut(name) + 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) { - *remaining -= 1; + available.reservations -= 1; } let exact = match recipient.key_name.as_deref() { Some(name) => available_public_keys_by_name .get(name) - .map(|(entry, _)| *entry), + .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(|(_, remaining)| !lax || *remaining == 0) - .map(|(entry, _)| *entry) + .filter(|available| !lax || available.reservations == 0) + .map(|available| available.entry) }), None if !lax && public_key_count > 1 => { return Err(CommandError::Usage( @@ -2284,28 +2333,35 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman } let fallbacks = store.public_keys().iter().filter(|entry| { lax && entry.usages.allows(key_manager::KeyUsage::Encrypt) - && available_public_keys_by_name - .get(entry.name.as_str()) - .is_some_and(|(_, remaining)| *remaining == 0) && !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; + } store_candidate_budget .consume(1) .map_err(|error| CommandError::Encryption(error.to_string()))?; - let candidate = entry - .rsa_encryption_key(&policy) - .map_err(CommandError::from) - .and_then(|public_key| { - validate_rsa_recipient_key(&public_key, &policy) - .map_err(|error| CommandError::Encryption(error.to_string()))?; - let candidate = RecipientPublicKeyCandidate { - public_key, - certificate_der: None, - }; - validate_recipient_key_metadata(metadata.as_ref(), &candidate)?; + let cached = available_public_keys_by_name + .get_mut(entry.name.as_str()) + .and_then(|available| available.loaded.take()); + let candidate = cached + .map_or_else(|| load_stored_recipient_candidate(entry, &policy), Ok) + .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 { @@ -2613,6 +2669,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), diff --git a/tools/xmlsec1/tests/process_contract.rs b/tools/xmlsec1/tests/process_contract.rs index 01692769..b3219b63 100644 --- a/tools/xmlsec1/tests/process_contract.rs +++ b/tools/xmlsec1/tests/process_contract.rs @@ -3882,6 +3882,44 @@ fn lax_store_rsa_recipients_consume_distinct_candidates() { 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( From 9c23ee693c20b820e83a8cb734a185f506612670 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 1 Oct 2026 15:35:34 +0300 Subject: [PATCH 5/6] fix(cli): charge cached recipients once --- docs/key-management.md | 3 + tools/xmlsec1/src/commands.rs | 38 +++++++----- tools/xmlsec1/tests/process_contract.rs | 79 +++++++++++++++++++++++++ 3 files changed, 104 insertions(+), 16 deletions(-) diff --git a/docs/key-management.md b/docs/key-management.md index 9e9747f8..5ac3bae3 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -120,6 +120,9 @@ 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 diff --git a/tools/xmlsec1/src/commands.rs b/tools/xmlsec1/src/commands.rs index b280e1b0..9eafd5c2 100644 --- a/tools/xmlsec1/src/commands.rs +++ b/tools/xmlsec1/src/commands.rs @@ -2345,25 +2345,31 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman { continue; } - store_candidate_budget - .consume(1) - .map_err(|error| CommandError::Encryption(error.to_string()))?; let cached = available_public_keys_by_name .get_mut(entry.name.as_str()) .and_then(|available| available.loaded.take()); - let candidate = cached - .map_or_else(|| load_stored_recipient_candidate(entry, &policy), Ok) - .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) - }); + // 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)); diff --git a/tools/xmlsec1/tests/process_contract.rs b/tools/xmlsec1/tests/process_contract.rs index b3219b63..78a16d86 100644 --- a/tools/xmlsec1/tests/process_contract.rs +++ b/tools/xmlsec1/tests/process_contract.rs @@ -4010,6 +4010,85 @@ fn lax_rsa_recipients_charge_only_attempted_store_keys() { assert_eq!(decrypt.stdout, b"two named recipients"); } +#[test] +fn lax_cached_recipients_charge_decoding_once() { + // Reservation validates immutable metadata and retains the decoded key. + // Reusing it must not consume another candidate, including at the full cap. + let temp = tempfile::tempdir().unwrap(); + let store = temp.path().join("keys.xml"); + let template = temp.path().join("template.xml"); + let plaintext = temp.path().join("plaintext.bin"); + let encrypted = temp.path().join("encrypted.xml"); + let public_pem = + fs::read_to_string(project_root().join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem")) + .unwrap(); + let public = RsaPublicKey::from_public_key_pem(&public_pem).unwrap(); + let base64 = base64::engine::general_purpose::STANDARD; + let modulus = base64.encode(public.n().to_be_bytes_trimmed_vartime()); + let exponent = base64.encode(public.e().to_be_bytes_trimmed_vartime()); + let key_value = format!( + "{modulus}{exponent}" + ); + fs::write(&plaintext, b"cached recipient boundary").unwrap(); + for count in [33, 64] { + let mut key_store = String::from( + "", + ); + let mut recipients = String::new(); + for index in 0..count { + key_store.push_str(&format!( + "recipient-{index}{key_value}" + )); + recipients.push_str(&format!( + "recipient-{index}{key_value}" + )); + } + key_store.push_str(""); + fs::write(&store, key_store).unwrap(); + fs::write(&template, format!( + "{recipients}" + )).unwrap(); + let encrypt = Command::new(binary()) + .args(["encrypt", "--lax-key-search", "--keys-file"]) + .arg(&store) + .arg("--binary-data") + .arg(&plaintext) + .arg("--output") + .arg(&encrypted) + .arg(&template) + .output() + .unwrap(); + assert!( + encrypt.status.success(), + "{count} recipients: {}", + String::from_utf8_lossy(&encrypt.stderr) + ); + let xml = fs::read_to_string(&encrypted).unwrap(); + let document = roxmltree::Document::parse(&xml).unwrap(); + let actual_names: Vec<_> = document + .descendants() + .filter(|node| node.has_tag_name(("http://www.w3.org/2000/09/xmldsig#", "KeyName"))) + .map(|node| node.text().unwrap()) + .collect(); + let expected_names: Vec<_> = (0..count) + .map(|index| format!("recipient-{index}")) + .collect(); + assert_eq!(actual_names, expected_names); + let decrypt = Command::new(binary()) + .args(["decrypt", "--privkey-pem:recipient-0"]) + .arg(project_root().join("tests/fixtures/keys/rsa/rsa-2048-key.pem")) + .arg(&encrypted) + .output() + .unwrap(); + assert!( + decrypt.status.success(), + "{}", + String::from_utf8_lossy(&decrypt.stderr) + ); + assert_eq!(decrypt.stdout, b"cached recipient boundary"); + } +} + #[test] fn encryption_writes_requested_diagnostics_and_rejects_duplicate_methods() { // Encryption diagnostics are a separate stdout contract, and malformed From d4f3bbd870b9166d56f60e49ea8def3d11eb457e Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Thu, 1 Oct 2026 17:32:23 +0300 Subject: [PATCH 6/6] fix(keys): replace pkcs12 import backend Use bounded BER views and RustCrypto primitives for PKCS12 imports. Enforce typed aggregate work, memory, and candidate limits before expensive operations, and keep direct AES keys out of recipient resolution. --- Cargo.toml | 10 +- README.md | 2 +- docs/key-management.md | 15 +- src/hard_limits.rs | 5 +- src/key_manager.rs | 388 ++++++---- src/key_manager/pkcs12_import.rs | 1201 ++++++++++++++++++++++++++++++ src/policy.rs | 4 +- src/xmlenc/decrypt.rs | 5 +- src/xmlenc/mod.rs | 1 + 9 files changed, 1478 insertions(+), 153 deletions(-) create mode 100644 src/key_manager/pkcs12_import.rs diff --git a/Cargo.toml b/Cargo.toml index 7f06f44b..bb48f210 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,8 +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 } -ribergshamra-core = { version = "0.11", default-features = false, optional = true } -ribergshamra-pkcs12 = { version = "0.11", default-features = false, features = ["rustcrypto", "legacy-algorithms"], 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 } @@ -154,8 +154,10 @@ xmldsig = [ # XML Digital Signatures (sign + verify) "dep:hmac", "dep:md-5", "dep:pem", - "dep:ribergshamra-core", - "dep:ribergshamra-pkcs12", + "dep:pkcs12", + "dep:pbkdf2", + "dep:aes", + "dep:cbc", "dep:peresil", "dep:p256", "dep:p384", diff --git a/README.md b/README.md index c71b3432..37e2b310 100644 --- a/README.md +++ b/README.md @@ -59,7 +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 PKCS#12 import | +| 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 | diff --git a/docs/key-management.md b/docs/key-management.md index 5ac3bae3..1d947b7c 100644 --- a/docs/key-management.md +++ b/docs/key-management.md @@ -71,8 +71,11 @@ and document CRLs, with their combined resource budget checked before copying. `add_private_der_with_password_callback` asks the caller for a zeroizing byte password only for encrypted PKCS#8; plaintext input does not invoke it. `add_pkcs12_with_password_callback` obtains a zeroizing string password before -decoding the bundle, after checking the encoded size and outer MAC KDF -parameters. Other KDF parameters are checked during full decoding. A missing +decoding the bundle, after checking encoded size, visible bag/container counts, +and all visible MAC/encryption KDF parameters against one aggregate work budget. +KDF parameters inside encrypted SafeContents cannot be inspected without the +password: they are checked immediately after outer decryption, before running +the inner derivation (RFC 7292 sections 4.1 and 4.2.2). A missing or wrong password returns a redacted error and never triggers an unprotected fallback. Oversized encoded bundles return a typed resource-policy error without invoking the callback. @@ -85,6 +88,14 @@ The CLI applies the same pre-decryption KDF limits to explicit protected PKCS#8 PEM/DER keys, including the generic private-key options, as to inventory imports. When the PKCS#12 parser rejects an oversized salt, that distinct resource rejection also returns a typed policy error. +The importer uses RustCrypto primitives with borrowed BER views; it supports +PBES2/PBKDF2 with AES-CBC and legacy SHA-1/3DES containers, plus SHA-1/SHA-2 MACs. +Private keys and decrypted temporary buffers are zeroized. Nested safe bags +share the same candidate and KDF budgets; a count denial is not a password error. +Temporary import allocations share the aggregate allowance with material already +retained by the inventory, and KDF workspaces are checked before derivation. +Named direct AES keys participate only in direct content-key resolution, not +in recipient-key unwrapping, so recipient hints cannot duplicate their candidate. ```rust use xml_sec::key_manager::{KeyInventory, KeyUsages, SymmetricKeyKind}; diff --git a/src/hard_limits.rs b/src/hard_limits.rs index 8ba897cb..bb5f72ef 100644 --- a/src/hard_limits.rs +++ b/src/hard_limits.rs @@ -58,9 +58,12 @@ pub(crate) const ENCRYPTION_RECIPIENT_CEILING: usize = 64; 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 memory reserved by one imported scrypt key derivation. +/// 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; diff --git a/src/key_manager.rs b/src/key_manager.rs index dd923f39..6736b884 100644 --- a/src/key_manager.rs +++ b/src/key_manager.rs @@ -12,7 +12,8 @@ use dsa::{ Components as DsaComponents, SigningKey as NativeDsaSigningKey, VerifyingKey as DsaVerifyingKey, pkcs8::EncodePrivateKey as _, }; -use ribergshamra_pkcs12::{Pkcs12Limits, parse_pkcs12_with_limits}; +mod pkcs12_import; +use pkcs12_import::Limits as Pkcs12Limits; #[cfg(feature = "xmlenc")] use rsa::pkcs8::DecodePublicKey as _; use rsa::{ @@ -575,6 +576,42 @@ enum ParsedMaterial { type ParsedDsaKey = (KeyValueInfo, Option>>); +#[cfg(feature = "xmlenc")] +struct InventoryDirectAes(Zeroizing>); + +#[cfg(feature = "xmlenc")] +impl crate::xmlenc::DecryptionKeyResolver for InventoryDirectAes { + fn resolve_key( + &self, + _provider: &dyn crate::provider::CryptoProvider, + algorithm: crate::xmlenc::DataEncryptionAlgorithm, + encrypted_key: Option<&crate::xmlenc::EncryptedKey>, + ) -> Result, crate::xmlenc::XmlEncError> { + if encrypted_key.is_some() { + return Err(crate::xmlenc::XmlEncError::KeyNotFound); + } + crate::xmlenc::validate_key_len(algorithm, &self.0)?; + Ok(self.0.to_vec()) + } + + fn resolve_key_candidates( + &self, + provider: &dyn crate::provider::CryptoProvider, + algorithm: crate::xmlenc::DataEncryptionAlgorithm, + encrypted_key: Option<&crate::xmlenc::EncryptedKey>, + budget: &mut crate::xmlenc::KeyCandidateBudget, + ) -> Result>, crate::xmlenc::XmlEncError> { + // This inventory entry is a content key, not a transport key. + // Ineligible recipient paths neither copy it nor consume candidates. + if encrypted_key.is_some() { + return Err(crate::xmlenc::XmlEncError::KeyNotFound); + } + budget.consume(1)?; + self.resolve_key(provider, algorithm, None) + .map(|key| vec![key]) + } +} + #[derive(Debug, thiserror::Error)] /// Key-store import errors that never include secret material. pub enum KeyStoreError { @@ -866,9 +903,9 @@ impl KeyInventory { )); } check_selected_material_size(entry.bytes.len(), &policy.resources)?; - return Ok(Box::new(crate::xmlenc::SymmetricKeyDecryptor::new( + return Ok(Box::new(InventoryDirectAes(Zeroizing::new( entry.bytes.to_vec(), - ))); + )))); } let entry = find_named_entry( &self.private_keys, @@ -1252,9 +1289,10 @@ impl KeyInventory { F: FnOnce() -> Option>, { let limits = self.pkcs12_import_limits(&name, bytes, usages, resources)?; - preflight_pkcs12_outer_mac_kdf(bytes, &limits)?; + let prepared = pkcs12_import::prepare(bytes, &limits)?; let secret = password().ok_or(KeyStoreError::ProtectedContainer)?; - self.add_pkcs12_with_usages(name, bytes, &secret, usages, resources) + let contents = prepared.decrypt(&secret)?; + self.add_pkcs12_contents(name, bytes.len(), contents, usages, resources, false) } /// Import a PKCS#12 bundle with explicit signing/decryption permissions. @@ -1274,13 +1312,24 @@ impl KeyInventory { name: String, bytes: &[u8], password: &str, - mut usages: KeyUsages, + usages: KeyUsages, resources: &ResourcePolicy, auto_decrypt: bool, ) -> Result<(), KeyStoreError> { let limits = self.pkcs12_import_limits(&name, bytes, usages, resources)?; - let mut contents = parse_pkcs12_with_limits(bytes, password, &limits) - .map_err(|error| classify_pkcs12_error(error, &limits))?; + let contents = pkcs12_import::prepare(bytes, &limits)?.decrypt(password)?; + self.add_pkcs12_contents(name, bytes.len(), contents, usages, resources, auto_decrypt) + } + + fn add_pkcs12_contents( + &mut self, + name: String, + encoded_len: usize, + mut contents: pkcs12_import::Contents, + mut usages: KeyUsages, + resources: &ResourcePolicy, + auto_decrypt: bool, + ) -> Result<(), KeyStoreError> { if contents.private_keys.len() != 1 { return Err(KeyStoreError::Selection( "PKCS#12 bundle must contain exactly one private key", @@ -1351,15 +1400,19 @@ impl KeyInventory { .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(KeyStoreError::Selection("key candidate limit exceeded")); + 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(bytes.len()), 1)?, + named_material_length(&name, decoded_bytes.max(encoded_len), 1)?, resources, )?; self.private_keys.push(StoredPrivateKey { name, - pkcs8_der: Zeroizing::new(private_key.as_ref().to_vec()), + pkcs8_der: private_key, usages, certificate_chain: certificates, has_matching_leaf, @@ -1392,27 +1445,13 @@ impl KeyInventory { } self.check_material_capacity(named_material_length(name, bytes.len(), 1)?, resources)?; let remaining_candidates = resources.max_key_candidates - self.entry_count; - let mut limits = Pkcs12Limits::default(); - limits.max_kdf_work = limits - .max_kdf_work - .min(resources.max_key_import_kdf_work as u64); - limits.max_iterations = limits - .max_iterations - .min(u32::try_from(resources.max_key_import_kdf_work).unwrap_or(u32::MAX)); - limits.max_input_len = limits - .max_input_len - .min(resources.max_external_resource_bytes); - limits.max_bags = limits.max_bags.min(remaining_candidates); - limits.max_content_infos = limits.max_content_infos.min(remaining_candidates); - if bytes.len() > limits.max_input_len { - return Err(KeyStoreError::Policy( - crate::policy::PolicyViolation::ResourceLimitExceeded { - resource: crate::policy::resource_name::EXTERNAL_RESOURCE_BYTES, - maximum: limits.max_input_len, - }, - )); - } - Ok(limits) + Ok(Pkcs12Limits { + resources: resources.clone(), + candidates: remaining_candidates, + memory_available: resources.max_external_resource_total_bytes + - self.material_bytes + - name.len(), + }) } fn check_new_name(&self, name: &str, resources: &ResourcePolicy) -> Result<(), KeyStoreError> { @@ -1942,89 +1981,6 @@ fn single_pem_block(bytes: &[u8], maximum: usize) -> Result KeyStoreError { - // The donor reports KDF and salt ceilings as untyped Key errors. Match - // only their exact diagnostics so wrong passwords and malformed bundles - // stay distinct; it does not expose the observed sizes. - match error { - ribergshamra_core::Error::Key(message) - if message == "PKCS#12 iterations outside configured limit" => - { - KeyStoreError::Policy(crate::policy::PolicyViolation::KdfIterationsOutsideLimit { - maximum: limits.max_iterations as usize, - }) - } - ribergshamra_core::Error::Key(message) => { - let (resource, maximum) = match message.as_str() { - "PKCS#12 salt exceeds configured size limit" => { - ("PKCS#12 salt bytes", limits.max_salt_len) - } - "PKCS#12 aggregate KDF work exceeds configured limit" => ( - crate::policy::resource_name::KEY_IMPORT_KDF_WORK, - usize::try_from(limits.max_kdf_work).unwrap_or(usize::MAX), - ), - _ => return KeyStoreError::ProtectedContainer, - }; - KeyStoreError::Policy(crate::policy::PolicyViolation::ResourceLimitExceeded { - resource, - maximum, - }) - } - _ => KeyStoreError::ProtectedContainer, - } -} - -fn preflight_pkcs12_outer_mac_kdf( - bytes: &[u8], - limits: &Pkcs12Limits, -) -> Result<(), KeyStoreError> { - use x509_parser::der_parser::ber::{Tag, parse_ber_slice, parse_ber_u32}; - - // Preflight the outer MacData without copying or decrypting the authenticated - // safe. The full parser still enforces limits on its other KDF parameters. - let Some(iterations) = (|| { - let (trailing, pfx) = parse_ber_slice(bytes, Tag::Sequence).ok()?; - if !trailing.is_empty() { - return None; - } - let (pfx, version) = parse_ber_u32(pfx).ok()?; - if version != 3 { - return None; - } - let (pfx, _) = parse_ber_slice(pfx, Tag::Sequence).ok()?; - let (trailing, mac) = parse_ber_slice(pfx, Tag::Sequence).ok()?; - if !trailing.is_empty() { - return None; - } - let (mac, _) = parse_ber_slice(mac, Tag::Sequence).ok()?; - let (mac, _) = parse_ber_slice(mac, Tag::OctetString).ok()?; - if mac.is_empty() { - Some(1) - } else { - let (trailing, iterations) = parse_ber_u32(mac).ok()?; - trailing.is_empty().then_some(iterations) - } - })() else { - return Ok(()); - }; - if iterations == 0 || iterations > limits.max_iterations { - return Err(KeyStoreError::Policy( - crate::policy::PolicyViolation::KdfIterationsOutsideLimit { - maximum: limits.max_iterations as usize, - }, - )); - } - if u64::from(iterations) > limits.max_kdf_work { - return Err(KeyStoreError::Policy( - crate::policy::PolicyViolation::ResourceLimitExceeded { - resource: crate::policy::resource_name::KEY_IMPORT_KDF_WORK, - maximum: usize::try_from(limits.max_kdf_work).unwrap_or(usize::MAX), - }, - )); - } - Ok(()) -} - fn enforce_pkcs8_kdf_policy( encrypted: &EncryptedPrivateKeyInfoRef<'_>, resources: &ResourcePolicy, @@ -2324,45 +2280,127 @@ mod tests { )); } + #[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() { - // The donor exposes a distinct aggregate-work diagnostic but no count. - let limits = Pkcs12Limits { - max_kdf_work: 2, - ..Pkcs12Limits::default() + // 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!( - classify_pkcs12_error( - ribergshamra_core::Error::Key( - "PKCS#12 aggregate KDF work exceeds configured limit".into() - ), - &limits, - ), - KeyStoreError::Policy(crate::policy::PolicyViolation::ResourceLimitExceeded { - resource: crate::policy::resource_name::KEY_IMPORT_KDF_WORK, - maximum: 2, - }) + 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!( - classify_pkcs12_error(ribergshamra_core::Error::Key("other".into()), &limits), - KeyStoreError::ProtectedContainer + 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_oversized_salt_preserves_policy_error() { - // The parser's own salt ceiling must not look like a wrong password. - let limits = Pkcs12Limits::default(); + 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!( - classify_pkcs12_error( - ribergshamra_core::Error::Key("PKCS#12 salt exceeds configured size limit".into()), - &limits, - ), - KeyStoreError::Policy(crate::policy::PolicyViolation::ResourceLimitExceeded { - resource: "PKCS#12 salt bytes", - maximum: 65_536, - }) + 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] @@ -4910,6 +4948,72 @@ mod tests { ); } + #[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() { diff --git a/src/key_manager/pkcs12_import.rs b/src/key_manager/pkcs12_import.rs new file mode 100644 index 00000000..14184f33 --- /dev/null +++ b/src/key_manager/pkcs12_import.rs @@ -0,0 +1,1201 @@ +//! Borrowed BER import orchestration; RustCrypto supplies cryptographic primitives. + +use aes::cipher::{BlockModeDecrypt, KeyIvInit, block_padding::Pkcs7}; +use core::ops::Deref; +use der::asn1::ObjectIdentifier as Oid; +use hmac::{Hmac, KeyInit as _, Mac as _}; +use pkcs12::kdf::{Pkcs12KeyType, derive_key}; +use zeroize::Zeroizing; + +use super::KeyStoreError; +use crate::policy::{PolicyViolation, ResourcePolicy, resource_name}; + +type Result = core::result::Result; +const DATA: Oid = Oid::new_unwrap("1.2.840.113549.1.7.1"); +const ENCRYPTED_DATA: Oid = Oid::new_unwrap("1.2.840.113549.1.7.6"); +const PBES2: Oid = Oid::new_unwrap("1.2.840.113549.1.5.13"); +const PBKDF2: Oid = Oid::new_unwrap("1.2.840.113549.1.5.12"); + +pub(super) struct Limits { + pub resources: ResourcePolicy, + pub candidates: usize, + pub memory_available: usize, +} + +pub(super) struct Contents { + pub private_keys: Vec>>, + pub certificates: Vec>, +} + +struct Budget<'a> { + limits: &'a Limits, + work: usize, + memory: usize, + bags: usize, + infos: usize, +} + +fn denial(resource: &'static str, maximum: usize) -> KeyStoreError { + PolicyViolation::ResourceLimitExceeded { resource, maximum }.into() +} + +fn malformed() -> Result { + Err(KeyStoreError::ProtectedContainer) +} + +impl<'a> Budget<'a> { + fn new(limits: &'a Limits) -> Self { + Self { + limits, + work: 0, + memory: 0, + bags: 0, + infos: 0, + } + } + + fn allocate(&mut self, size: usize) -> Result<()> { + let maximum = self.limits.resources.max_external_resource_total_bytes; + if size > self.limits.memory_available - self.memory { + return Err(denial( + resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + maximum, + )); + } + self.memory += size; + Ok(()) + } + + fn copy(&mut self, bytes: &[u8]) -> Result>> { + self.allocate(bytes.len())?; + Ok(Zeroizing::new(bytes.to_vec())) + } + + fn count(&mut self, bag: bool) -> Result<()> { + let count = if bag { &mut self.bags } else { &mut self.infos }; + if *count >= self.limits.candidates { + return Err(denial( + resource_name::KEY_CANDIDATES, + self.limits.candidates, + )); + } + *count += 1; + Ok(()) + } + + fn kdf(&mut self, rounds: u32, blocks: usize, salt: &[u8]) -> Result<()> { + let maximum = self.limits.resources.max_key_import_kdf_work; + if rounds == 0 || u64::from(rounds) > maximum as u64 || rounds > i32::MAX as u32 { + return Err(PolicyViolation::KdfIterationsOutsideLimit { maximum }.into()); + } + let salt_maximum = self.limits.resources.max_external_resource_bytes; + if salt.len() > salt_maximum { + return Err(denial("PKCS#12 salt bytes", salt_maximum)); + } + let work = (rounds as usize) + .checked_mul(blocks) + .ok_or_else(|| denial(resource_name::KEY_IMPORT_KDF_WORK, maximum))?; + if work > maximum - self.work { + return Err(denial(resource_name::KEY_IMPORT_KDF_WORK, maximum)); + } + self.work += work; + Ok(()) + } + + fn legacy_workspace( + &self, + salt: &[u8], + password: &[u8], + block: usize, + output: usize, + ) -> Result<()> { + // RFC 7292 B.2 rounds salt and password up to digest blocks. Account + // for the KDF's I, diversifier and output before RustCrypto allocates. + // https://www.rfc-editor.org/rfc/rfc7292#appendix-B.2 + let size = salt + .len() + .div_ceil(block) + .checked_add(password.len().div_ceil(block)) + .and_then(|n| n.checked_mul(block)) + .and_then(|n| n.checked_add(block + output)) + .ok_or_else(|| { + denial( + resource_name::KEY_IMPORT_KDF_MEMORY, + self.limits.resources.max_key_import_kdf_memory_bytes, + ) + })?; + if size > self.limits.resources.max_key_import_kdf_memory_bytes { + return Err(denial( + resource_name::KEY_IMPORT_KDF_MEMORY, + self.limits.resources.max_key_import_kdf_memory_bytes, + )); + } + if size > self.limits.memory_available - self.memory { + return Err(denial( + resource_name::AGGREGATE_EXTERNAL_RESOURCE_BYTES, + self.limits.resources.max_external_resource_total_bytes, + )); + } + Ok(()) + } +} + +/// A TLV view never creates an ASN.1 object tree. Indefinite BER is accepted +/// as required by RFC 7292 4.1; recursion has an absolute stack-safety ceiling. +#[derive(Clone, Copy)] +struct Tlv<'a> { + tag: u8, + value: &'a [u8], +} + +fn tlv(bytes: &[u8], depth: usize) -> Result<(Tlv<'_>, &[u8])> { + if depth >= crate::hard_limits::PKCS12_NESTING_CEILING || bytes.len() < 2 { + return malformed(); + } + let tag = bytes[0]; + if tag & 0x1f == 0x1f || tag == 0 { + return malformed(); + } + let mut start = 2; + let end; + let consumed; + if bytes[1] == 0x80 { + if tag & 0x20 == 0 { + return malformed(); + } + let mut remaining = &bytes[start..]; + loop { + if remaining.starts_with(&[0, 0]) { + end = bytes.len() - remaining.len(); + consumed = end + 2; + break; + } + remaining = tlv(remaining, depth + 1)?.1; + } + } else { + let mut length = usize::from(bytes[1]); + if length & 0x80 != 0 { + let count = length & 0x7f; + if count == 0 || count > core::mem::size_of::() || count > bytes.len() - start { + return malformed(); + } + length = 0; + for byte in &bytes[start..start + count] { + length = length + .checked_mul(256) + .and_then(|v| v.checked_add(usize::from(*byte))) + .ok_or(KeyStoreError::ProtectedContainer)?; + } + start += count; + } + if length > bytes.len() - start { + return malformed(); + } + end = start + length; + consumed = end; + } + Ok(( + Tlv { + tag, + value: &bytes[start..end], + }, + &bytes[consumed..], + )) +} + +struct Reader<'a>(&'a [u8]); +impl<'a> Reader<'a> { + fn take(&mut self, tag: u8) -> Result> { + let (value, rest) = tlv(self.0, 0)?; + if value.tag != tag { + return malformed(); + } + self.0 = rest; + Ok(value) + } + fn sequence(bytes: &'a [u8]) -> Result { + let mut outer = Self(bytes); + let sequence = outer.take(0x30)?; + outer.finish()?; + Ok(Self(sequence.value)) + } + fn finish(self) -> Result<()> { + if self.0.is_empty() { + Ok(()) + } else { + malformed() + } + } + fn oid(&mut self) -> Result { + Oid::from_bytes(self.take(6)?.value).map_err(|_| KeyStoreError::ProtectedContainer) + } + fn integer(&mut self) -> Result { + let bytes = self.take(2)?.value; + // X.690 8.3.2 forbids redundant sign octets in BER INTEGER too, + // not only DER; all these fields require nonnegative values. + // https://www.itu.int/rec/T-REC-X.690-202102-I/en + if bytes.is_empty() + || bytes[0] & 0x80 != 0 + || (bytes.len() > 1 && bytes[0] == 0 && bytes[1] & 0x80 == 0) + { + return malformed(); + } + bytes + .iter() + .try_fold(0_u32, |n, b| { + n.checked_mul(256) + .and_then(|n| n.checked_add(u32::from(*b))) + }) + .ok_or(KeyStoreError::ProtectedContainer) + } + fn null_or_absent(&mut self) -> Result<()> { + if !self.0.is_empty() && !self.take(5)?.value.is_empty() { + return malformed(); + } + Self(self.0).finish() + } +} + +enum Bytes<'a> { + Borrowed(&'a [u8]), + Owned(Zeroizing>), +} +impl Deref for Bytes<'_> { + type Target = [u8]; + fn deref(&self) -> &[u8] { + match self { + Self::Borrowed(v) => v, + Self::Owned(v) => v, + } + } +} +impl Bytes<'_> { + fn owned_capacity(&self) -> usize { + match self { + Self::Borrowed(_) => 0, + Self::Owned(v) => v.capacity(), + } + } + fn release(&self, budget: &mut Budget<'_>) { + if let Self::Owned(v) = self { + budget.memory -= v.capacity(); + } + } +} + +fn octet_visit(value: Tlv<'_>, primitive: u8, depth: usize, visit: &mut F) -> Result<()> +where + F: FnMut(&[u8]) -> Result<()>, +{ + if depth >= crate::hard_limits::PKCS12_NESTING_CEILING { + return malformed(); + } + if value.tag == primitive { + return visit(value.value); + } + if value.tag != primitive | 0x20 { + return malformed(); + } + let mut children = value.value; + while !children.is_empty() { + let (child, rest) = tlv(children, depth)?; + // Constructed implicit [0] OCTET STRING has universal OCTET children. + octet_visit(child, 4, depth + 1, visit)?; + children = rest; + } + Ok(()) +} + +fn octets<'a>(value: Tlv<'a>, primitive: u8, budget: &mut Budget<'_>) -> Result> { + if value.tag == primitive { + return Ok(Bytes::Borrowed(value.value)); + } + let mut size = 0_usize; + octet_visit(value, primitive, 0, &mut |bytes| { + size = size + .checked_add(bytes.len()) + .ok_or(KeyStoreError::ProtectedContainer)?; + Ok(()) + })?; + budget.allocate(size)?; + let mut output = Zeroizing::new(Vec::with_capacity(size)); + octet_visit(value, primitive, 0, &mut |bytes| { + output.extend_from_slice(bytes); + Ok(()) + })?; + Ok(Bytes::Owned(output)) +} + +#[derive(Clone, Copy)] +enum Hash { + Sha1, + Sha224, + Sha256, + Sha384, + Sha512, +} +impl Hash { + fn size(self) -> usize { + match self { + Self::Sha1 => 20, + Self::Sha224 => 28, + Self::Sha256 => 32, + Self::Sha384 => 48, + Self::Sha512 => 64, + } + } + fn block(self) -> usize { + match self { + Self::Sha384 | Self::Sha512 => 128, + _ => 64, + } + } + fn from_oid(oid: Oid, hmac: bool) -> Result { + let choices = if hmac { + [ + "1.2.840.113549.2.7", + "1.2.840.113549.2.8", + "1.2.840.113549.2.9", + "1.2.840.113549.2.10", + "1.2.840.113549.2.11", + ] + } else { + [ + "1.3.14.3.2.26", + "2.16.840.1.101.3.4.2.4", + "2.16.840.1.101.3.4.2.1", + "2.16.840.1.101.3.4.2.2", + "2.16.840.1.101.3.4.2.3", + ] + }; + for (text, hash) in choices.into_iter().zip([ + Self::Sha1, + Self::Sha224, + Self::Sha256, + Self::Sha384, + Self::Sha512, + ]) { + if oid == Oid::new_unwrap(text) { + return Ok(hash); + } + } + malformed() + } +} + +macro_rules! with_hash { + ($hash:expr, $digest:ident, $body:expr) => { + match $hash { + Hash::Sha1 => { + type $digest = sha1::Sha1; + $body + } + Hash::Sha224 => { + type $digest = sha2::Sha224; + $body + } + Hash::Sha256 => { + type $digest = sha2::Sha256; + $body + } + Hash::Sha384 => { + type $digest = sha2::Sha384; + $body + } + Hash::Sha512 => { + type $digest = sha2::Sha512; + $body + } + } + }; +} + +struct Mac<'a> { + hash: Hash, + digest: Bytes<'a>, + salt: Bytes<'a>, + rounds: u32, +} +impl<'a> Mac<'a> { + fn parse(encoded: Tlv<'a>, budget: &mut Budget<'_>) -> Result { + let mut mac = Reader(encoded.value); + let mut digest_info = Reader(mac.take(0x30)?.value); + let mut algorithm = Reader(digest_info.take(0x30)?.value); + let hash = Hash::from_oid(algorithm.oid()?, false)?; + algorithm.null_or_absent()?; + let (value, rest) = tlv(digest_info.0, 0)?; + let digest = octets(value, 4, budget)?; + digest_info.0 = rest; + digest_info.finish()?; + if digest.len() != hash.size() { + return malformed(); + } + let (salt_tlv, rest) = tlv(mac.0, 0)?; + let salt = octets(salt_tlv, 4, budget)?; + mac.0 = rest; + let rounds = if mac.0.is_empty() { 1 } else { mac.integer()? }; + mac.finish()?; + budget.kdf(rounds, 1, &salt)?; + Ok(Self { + hash, + digest, + salt, + rounds, + }) + } + fn verify(&self, bytes: &[u8], password: &[u8], budget: &Budget<'_>) -> Result<()> { + budget.legacy_workspace(&self.salt, password, self.hash.block(), self.hash.size())?; + let key = Zeroizing::new(with_hash!( + self.hash, + D, + derive_key::( + password, + &self.salt, + Pkcs12KeyType::Mac, + self.rounds as i32, + self.hash.size() + ) + )); + with_hash!(self.hash, D, { + let mut mac = + Hmac::::new_from_slice(&key).map_err(|_| KeyStoreError::ProtectedContainer)?; + mac.update(bytes); + mac.verify_slice(&self.digest) + .map_err(|_| KeyStoreError::ProtectedContainer) + }) + } +} + +#[derive(Clone, Copy)] +enum Cipher { + Aes128, + Aes192, + Aes256, + TripleDes, + DoubleDes, +} +impl Cipher { + fn key_len(self) -> usize { + match self { + Self::Aes128 | Self::DoubleDes => 16, + Self::Aes192 | Self::TripleDes => 24, + Self::Aes256 => 32, + } + } + fn block(self) -> usize { + match self { + Self::TripleDes | Self::DoubleDes => 8, + _ => 16, + } + } +} + +struct Encryption<'a> { + cipher: Cipher, + salt: Bytes<'a>, + rounds: u32, + hash: Option, + iv: &'a [u8], +} +impl<'a> Encryption<'a> { + fn parse(encoded: Tlv<'a>, budget: &mut Budget<'_>) -> Result { + let mut algorithm = Reader(encoded.value); + let oid = algorithm.oid()?; + let mut params = Reader(algorithm.take(0x30)?.value); + algorithm.finish()?; + let (cipher, salt, rounds, hash, iv); + if oid == PBES2 { + let mut kdf = Reader(params.take(0x30)?.value); + if kdf.oid()? != PBKDF2 { + return malformed(); + } + let mut derivation = Reader(kdf.take(0x30)?.value); + kdf.finish()?; + let (value, rest) = tlv(derivation.0, 0)?; + salt = octets(value, 4, budget)?; + derivation.0 = rest; + rounds = derivation.integer()?; + let length = if derivation.0.first() == Some(&2) { + Some(derivation.integer()?) + } else { + None + }; + let mut prf = Hash::Sha1; + if !derivation.0.is_empty() { + let mut algorithm = Reader(derivation.take(0x30)?.value); + prf = Hash::from_oid(algorithm.oid()?, true)?; + algorithm.null_or_absent()?; + } + derivation.finish()?; + let mut scheme = Reader(params.take(0x30)?.value); + let oid = scheme.oid()?; + cipher = if oid == pkcs8::pkcs5::pbes2::AES_128_CBC_OID { + Cipher::Aes128 + } else if oid == pkcs8::pkcs5::pbes2::AES_192_CBC_OID { + Cipher::Aes192 + } else if oid == pkcs8::pkcs5::pbes2::AES_256_CBC_OID { + Cipher::Aes256 + } else { + return malformed(); + }; + iv = scheme.take(4)?.value; + scheme.finish()?; + if iv.len() != cipher.block() + || length.is_some_and(|length| length as usize != cipher.key_len()) + { + return malformed(); + } + hash = Some(prf); + budget.kdf(rounds, cipher.key_len().div_ceil(prf.size()), &salt)?; + } else { + cipher = if oid == pkcs12::PKCS_12_PBE_WITH_SHAAND3_KEY_TRIPLE_DES_CBC { + Cipher::TripleDes + } else if oid == pkcs12::PKCS_12_PBE_WITH_SHAAND2_KEY_TRIPLE_DES_CBC { + Cipher::DoubleDes + } else { + return malformed(); + }; + let (value, rest) = tlv(params.0, 0)?; + salt = octets(value, 4, budget)?; + params.0 = rest; + rounds = params.integer()?; + hash = None; + iv = &[]; + // Appendix B.2 derives key and IV separately; 24-byte SHA-1 + // keys need two digest blocks, not one iteration charge. + budget.kdf(rounds, cipher.key_len().div_ceil(20) + 1, &salt)?; + } + params.finish()?; + Ok(Self { + cipher, + salt, + rounds, + hash, + iv, + }) + } + + fn decrypt( + &self, + ciphertext: &[u8], + password: &mut Password<'_>, + budget: &mut Budget<'_>, + ) -> Result>> { + if ciphertext.is_empty() || !ciphertext.len().is_multiple_of(self.cipher.block()) { + return malformed(); + } + let mut key = Zeroizing::new([0_u8; 32]); + let mut iv = Zeroizing::new([0_u8; 16]); + if let Some(hash) = self.hash { + with_hash!( + hash, + D, + pbkdf2::pbkdf2_hmac::( + password.utf8.as_bytes(), + &self.salt, + self.rounds, + &mut key[..self.cipher.key_len()] + ) + ); + iv[..self.iv.len()].copy_from_slice(self.iv); + } else { + let bmp = password.bmp(budget)?; + budget.legacy_workspace(&self.salt, bmp, 64, self.cipher.key_len())?; + let derived = Zeroizing::new(derive_key::( + bmp, + &self.salt, + Pkcs12KeyType::EncryptionKey, + self.rounds as i32, + self.cipher.key_len(), + )); + key[..derived.len()].copy_from_slice(&derived); + drop(derived); + let derived = Zeroizing::new(derive_key::( + bmp, + &self.salt, + Pkcs12KeyType::Iv, + self.rounds as i32, + 8, + )); + iv[..8].copy_from_slice(&derived); + drop(derived); + } + let mut plaintext = budget.copy(ciphertext)?; + macro_rules! decrypt { + ($cipher:ty) => { + cbc::Decryptor::<$cipher>::new_from_slices( + &key[..self.cipher.key_len()], + &iv[..self.cipher.block()], + ) + .map_err(|_| KeyStoreError::ProtectedContainer)? + .decrypt_padded::(&mut plaintext) + .map_err(|_| KeyStoreError::ProtectedContainer)? + .len() + }; + } + let length = match self.cipher { + Cipher::Aes128 => decrypt!(aes::Aes128Dec), + Cipher::Aes192 => decrypt!(aes::Aes192Dec), + Cipher::Aes256 => decrypt!(aes::Aes256Dec), + Cipher::TripleDes => decrypt!(des::TdesEde3), + Cipher::DoubleDes => decrypt!(des::TdesEde2), + }; + plaintext.truncate(length); + Ok(plaintext) + } +} + +fn content_info<'a>(encoded: Tlv<'a>) -> Result<(Oid, Tlv<'a>)> { + let mut info = Reader(encoded.value); + let oid = info.oid()?; + let explicit = info.take(0xa0)?; + info.finish()?; + let (content, rest) = tlv(explicit.value, 0)?; + Reader(rest).finish()?; + Ok((oid, content)) +} + +fn encrypted_content<'a>( + encoded: Tlv<'a>, + budget: &mut Budget<'_>, +) -> Result<(Encryption<'a>, Bytes<'a>)> { + if encoded.tag != 0x30 { + return malformed(); + } + let mut data = Reader(encoded.value); + if data.integer()? != 0 { + return malformed(); + } + let mut info = Reader(data.take(0x30)?.value); + data.finish()?; + if info.oid()? != DATA { + return malformed(); + } + let encryption = Encryption::parse(info.take(0x30)?, budget)?; + let (value, rest) = tlv(info.0, 0)?; + Reader(rest).finish()?; + Ok((encryption, octets(value, 0x80, budget)?)) +} + +struct Password<'a> { + utf8: &'a str, + bmp: Option>>, +} + +impl Password<'_> { + fn bmp(&mut self, budget: &mut Budget<'_>) -> Result<&[u8]> { + if self.bmp.is_none() { + // RFC 7292 B.1's BMPString conversion applies to its legacy KDF, + // not PBES2 (RFC 8018 6.2). Convert lazily so UTF-8 PBES2-only + // containers neither allocate this buffer nor reject non-BMP text. + // https://www.rfc-editor.org/rfc/rfc7292#appendix-B.1 + // https://www.rfc-editor.org/rfc/rfc8018#section-6.2 + let mut units = 1_usize; + for ch in self.utf8.chars() { + if u32::from(ch) > u16::MAX as u32 { + return malformed(); + } + units = units + .checked_add(1) + .ok_or(KeyStoreError::ProtectedContainer)?; + } + let size = units + .checked_mul(2) + .ok_or(KeyStoreError::ProtectedContainer)?; + budget.allocate(size)?; + let mut bmp = Zeroizing::new(Vec::with_capacity(size)); + for ch in self.utf8.chars() { + bmp.extend_from_slice(&(u32::from(ch) as u16).to_be_bytes()); + } + bmp.extend_from_slice(&[0, 0]); + self.bmp = Some(bmp); + } + self.bmp + .as_deref() + .map(|bytes| bytes.as_slice()) + .ok_or(KeyStoreError::ProtectedContainer) + } +} + +fn validate_attribute_values(mut bytes: &[u8], depth: usize) -> Result<()> { + if depth >= crate::hard_limits::PKCS12_NESTING_CEILING { + return malformed(); + } + while !bytes.is_empty() { + let (value, rest) = tlv(bytes, depth)?; + if value.tag & 0x20 != 0 { + validate_attribute_values(value.value, depth + 1)?; + } + bytes = rest; + } + Ok(()) +} + +fn validate_attributes(mut attributes: Reader<'_>) -> Result<()> { + // RFC 7292 4.2 defines each optional PKCS12Attribute as an OID and + // a SET OF values. Ignoring an attribute's meaning does not waive its + // framing; validate without retaining or decoding the metadata. + // https://www.rfc-editor.org/rfc/rfc7292#section-4.2 + while !attributes.0.is_empty() { + let mut attribute = Reader(attributes.take(0x30)?.value); + attribute.oid()?; + validate_attribute_values(attribute.take(0x31)?.value, 0)?; + attribute.finish()?; + } + Ok(()) +} + +fn safe_contents( + bytes: &[u8], + budget: &mut Budget<'_>, + mut password: Option<&mut Password<'_>>, + contents: &mut Contents, + depth: usize, +) -> Result<()> { + if depth >= crate::hard_limits::PKCS12_NESTING_CEILING { + return malformed(); + } + let mut safe = Reader::sequence(bytes)?; + while !safe.0.is_empty() { + budget.count(true)?; + let mut bag = Reader(safe.take(0x30)?.value); + let oid = bag.oid()?; + let value = bag.take(0xa0)?.value; + if !bag.0.is_empty() { + validate_attributes(Reader(bag.take(0x31)?.value))?; + } + bag.finish()?; + if oid == pkcs12::PKCS_12_SAFE_CONTENTS_BAG_OID { + safe_contents(value, budget, password.as_deref_mut(), contents, depth + 1)?; + } else if oid == pkcs12::PKCS_12_PKCS8_KEY_BAG_OID { + let mut key = Reader::sequence(value)?; + let encryption = Encryption::parse(key.take(0x30)?, budget)?; + let (encrypted, rest) = tlv(key.0, 0)?; + Reader(rest).finish()?; + let encrypted = octets(encrypted, 4, budget)?; + if let Some(password) = password.as_deref_mut() { + if !contents.private_keys.is_empty() { + return Err(KeyStoreError::Selection( + "PKCS#12 bundle must contain exactly one private key", + )); + } + budget.allocate(core::mem::size_of::>>())?; + contents.private_keys.reserve_exact(1); + contents + .private_keys + .push(encryption.decrypt(&encrypted, password, budget)?); + } + encrypted.release(budget); + encryption.salt.release(budget); + } else if oid == pkcs12::PKCS_12_KEY_BAG_OID { + if password.is_some() { + if !contents.private_keys.is_empty() { + return Err(KeyStoreError::Selection( + "PKCS#12 bundle must contain exactly one private key", + )); + } + budget.allocate(core::mem::size_of::>>())?; + contents.private_keys.reserve_exact(1); + contents.private_keys.push(budget.copy(value)?); + } + } else if oid == pkcs12::PKCS_12_CERT_BAG_OID { + let mut cert = Reader::sequence(value)?; + if cert.oid()? != pkcs12::PKCS_12_X509_CERT_OID { + return malformed(); + } + let explicit = cert.take(0xa0)?; + cert.finish()?; + let (value, rest) = tlv(explicit.value, 0)?; + Reader(rest).finish()?; + let certificate = octets(value, 4, budget)?; + if password.is_some() { + if contents.certificates.capacity() == 0 { + // Reserve the bounded bag allowance only when a certificate + // actually exists, avoiding speculative allocation for + // key-only containers and growth during hidden-bag traversal. + budget.allocate(budget.limits.candidates * core::mem::size_of::>())?; + contents + .certificates + .reserve_exact(budget.limits.candidates); + } + // Retained public certificate is independent of temporary decrypted bags. + budget.allocate(certificate.len())?; + contents.certificates.push(certificate.to_vec()); + } + certificate.release(budget); + } else { + return Err(KeyStoreError::Selection("unsupported PKCS#12 bag type")); + } + } + Ok(()) +} + +fn walk_safe( + bytes: &[u8], + budget: &mut Budget<'_>, + mut password: Option<&mut Password<'_>>, + contents: &mut Contents, +) -> Result<()> { + let mut safe = Reader::sequence(bytes)?; + while !safe.0.is_empty() { + budget.count(false)?; + let (oid, content) = content_info(safe.take(0x30)?)?; + if oid == DATA { + let data = octets(content, 4, budget)?; + safe_contents(&data, budget, password.as_deref_mut(), contents, 0)?; + data.release(budget); + } else if oid == ENCRYPTED_DATA { + let (encryption, encrypted) = encrypted_content(content, budget)?; + if let Some(password) = password.as_deref_mut() { + let data = encryption.decrypt(&encrypted, password, budget)?; + // RFC 7292 4.1/4.2.2 allows shrouded bags inside encrypted + // SafeContents. Their parameters cannot be known before the + // password; the shared budget checks them before their KDF. + // https://www.rfc-editor.org/rfc/rfc7292#section-4.1 + safe_contents(&data, budget, Some(password), contents, 0)?; + budget.memory -= data.capacity(); + } + encrypted.release(budget); + encryption.salt.release(budget); + } else { + return Err(KeyStoreError::Selection("unsupported PKCS#12 privacy mode")); + } + } + Ok(()) +} + +struct Pfx<'a> { + safe: Bytes<'a>, + mac: Option>, +} +impl<'a> Pfx<'a> { + fn parse(bytes: &'a [u8], budget: &mut Budget<'_>) -> Result { + let mut pfx = Reader::sequence(bytes)?; + if pfx.integer()? != 3 { + return malformed(); + } + let (oid, content) = content_info(pfx.take(0x30)?)?; + if oid != DATA { + return Err(KeyStoreError::Selection( + "unsupported PKCS#12 integrity mode", + )); + } + let safe = octets(content, 4, budget)?; + let mac = if pfx.0.is_empty() { + None + } else { + Some(Mac::parse(pfx.take(0x30)?, budget)?) + }; + pfx.finish()?; + Ok(Self { safe, mac }) + } +} + +pub(super) struct Prepared<'a, 'l> { + pfx: Pfx<'a>, + limits: &'l Limits, +} + +pub(super) fn prepare<'a, 'l>(bytes: &'a [u8], limits: &'l Limits) -> Result> { + let mut budget = Budget::new(limits); + let pfx = Pfx::parse(bytes, &mut budget)?; + walk_safe( + &pfx.safe, + &mut budget, + None, + &mut Contents { + private_keys: Vec::new(), + certificates: Vec::new(), + }, + )?; + Ok(Prepared { pfx, limits }) +} + +impl Prepared<'_, '_> { + pub(super) fn decrypt(self, password: &str) -> Result { + let Self { pfx, limits } = self; + let mut budget = Budget::new(limits); + budget.allocate(pfx.safe.owned_capacity())?; + if let Some(mac) = &pfx.mac { + budget.allocate(mac.salt.owned_capacity() + mac.digest.owned_capacity())?; + budget.kdf(mac.rounds, 1, &mac.salt)?; + } + let mut password = Password { + utf8: password, + bmp: None, + }; + if let Some(mac) = &pfx.mac { + mac.verify(&pfx.safe, password.bmp(&mut budget)?, &budget)?; + } + let mut contents = Contents { + private_keys: Vec::new(), + certificates: Vec::new(), + }; + walk_safe(&pfx.safe, &mut budget, Some(&mut password), &mut contents)?; + Ok(contents) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aes::cipher::BlockModeEncrypt as _; + + fn encoded(tag: u8, value: &[u8]) -> Vec { + let mut out = vec![tag]; + if value.len() < 128 { + out.push(value.len() as u8); + } else { + out.push(0x82); + out.extend_from_slice(&(value.len() as u16).to_be_bytes()); + } + out.extend_from_slice(value); + out + } + fn sequence(parts: &[Vec]) -> Vec { + encoded(0x30, &parts.concat()) + } + fn oid(value: Oid) -> Vec { + encoded(6, value.as_bytes()) + } + fn integer(value: u16) -> Vec { + let mut bytes = value.to_be_bytes().to_vec(); + if bytes[0] == 0 && bytes[1] < 128 { + bytes.remove(0); + } else if bytes[0] & 128 != 0 { + bytes.insert(0, 0); + } + encoded(2, &bytes) + } + fn bag(kind: Oid, value: &[u8]) -> Vec { + sequence(&[oid(kind), encoded(0xa0, value)]) + } + fn data(safe: &[u8]) -> Vec { + sequence(&[oid(DATA), encoded(0xa0, &encoded(4, safe))]) + } + fn pfx(infos: &[Vec]) -> Vec { + sequence(&[integer(3), data(&sequence(infos))]) + } + fn limits(candidates: usize) -> Limits { + Limits { + resources: ResourcePolicy::default(), + candidates, + memory_available: ResourcePolicy::default().max_external_resource_total_bytes, + } + } + + #[test] + fn malformed_bag_attributes_are_rejected_before_password() { + // Optional attributes are still ASN.1 Attribute records, not an + // unchecked opaque tail that can hide malformed BER. + let key = sequence(&[ + oid(pkcs12::PKCS_12_KEY_BAG_OID), + encoded(0xa0, &[0x30, 0]), + encoded(0x31, &[0xff]), + ]); + assert!(prepare(&pfx(&[data(&sequence(&[key]))]), &limits(64)).is_err()); + } + + #[test] + fn redundant_integer_octets_are_not_ber() { + // X.690 8.3.2 disallows a redundant leading zero even for BER. + assert!(Reader(&[2, 2, 0, 3]).integer().is_err()); + } + + #[test] + fn nested_bags_share_candidate_count() { + // A nested SafeContentsBag is not a reset of the outer bag budget. + let key = bag(pkcs12::PKCS_12_KEY_BAG_OID, &[0x30, 0]); + let nested = bag(pkcs12::PKCS_12_SAFE_CONTENTS_BAG_OID, &sequence(&[key])); + assert!(matches!( + prepare(&pfx(&[data(&sequence(&[nested]))]), &limits(1)), + Err(KeyStoreError::Policy( + PolicyViolation::ResourceLimitExceeded { + resource: resource_name::KEY_CANDIDATES, + maximum: 1 + } + )) + )); + } + + #[test] + fn constructed_octets_preserve_mac_input_and_ownership() { + // Constructed OCTET STRING concatenates primitive contents; its + // allocation must be charged once and released by retained capacity. + let value = [0x24, 0x80, 4, 2, b'a', b'b', 0x24, 3, 4, 1, b'c', 0, 0]; + let limits = limits(64); + let mut budget = Budget::new(&limits); + let bytes = octets(tlv(&value, 0).expect("BER").0, 4, &mut budget).expect("flatten"); + assert_eq!(&*bytes, b"abc"); + assert_eq!(budget.memory, 3); + bytes.release(&mut budget); + assert_eq!(budget.memory, 0); + assert!(tlv(&[4, 0x80, 0, 0], 0).is_err()); + assert!(tlv(&[0x30, 0x80, 4, 1, 7], 0).is_err()); + } + + #[test] + fn legacy_shrouded_key_and_hidden_limits() { + // Exercise RustCrypto's PKCS#12 KDF with legacy SHA-1/3DES, then + // prove an encrypted SafeContents cannot reset the inner KDF budget. + let password = "secret"; + let bmp: Vec = password + .encode_utf16() + .chain([0]) + .flat_map(u16::to_be_bytes) + .collect(); + let salt = b"12345678"; + let key = derive_key::(&bmp, salt, Pkcs12KeyType::EncryptionKey, 2, 24); + let iv = derive_key::(&bmp, salt, Pkcs12KeyType::Iv, 2, 8); + let algorithm = sequence(&[ + oid(pkcs12::PKCS_12_PBE_WITH_SHAAND3_KEY_TRIPLE_DES_CBC), + sequence(&[encoded(4, salt), integer(2)]), + ]); + let private = pem::parse(include_bytes!( + "../../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("key") + .into_contents(); + let encrypt = |bytes: &[u8]| { + let mut output = vec![0; bytes.len() + 8]; + cbc::Encryptor::::new_from_slices(&key, &iv) + .expect("cipher") + .encrypt_padded_b2b::(bytes, &mut output) + .expect("padding") + .to_vec() + }; + let shrouded = bag( + pkcs12::PKCS_12_PKCS8_KEY_BAG_OID, + &sequence(&[algorithm.clone(), encoded(4, &encrypt(&private))]), + ); + let bytes = pfx(&[data(&sequence(std::slice::from_ref(&shrouded)))]); + let limits = limits(64); + let contents = prepare(&bytes, &limits) + .expect("preflight") + .decrypt(password) + .expect("legacy import"); + assert_eq!(&*contents.private_keys[0], &private); + assert!( + prepare(&bytes, &limits) + .expect("preflight") + .decrypt("wrong") + .is_err() + ); + let encrypted_safe = sequence(&[ + oid(ENCRYPTED_DATA), + encoded( + 0xa0, + &sequence(&[ + integer(0), + sequence(&[ + oid(DATA), + algorithm, + encoded(0x80, &encrypt(&sequence(&[shrouded]))), + ]), + ]), + ), + ]); + let bytes = pfx(&[encrypted_safe]); + let tight = Limits { + resources: ResourcePolicy { + max_key_import_kdf_work: 6, + ..ResourcePolicy::default() + }, + candidates: 64, + memory_available: ResourcePolicy::default().max_external_resource_total_bytes, + }; + let prepared = prepare(&bytes, &tight).expect("outer KDF fits"); + assert!(matches!( + prepared.decrypt(password), + Err(KeyStoreError::Policy( + PolicyViolation::ResourceLimitExceeded { + resource: resource_name::KEY_IMPORT_KDF_WORK, + maximum: 6 + } + )) + )); + } + + #[test] + fn pbes2_cipher_prf_matrix_accepts_utf8_passwords_without_legacy_kdf() { + // RFC 8018 PBES2 does not impose RFC 7292's legacy BMP password + // conversion. Test every supported AES width and HMAC PRF with a + // non-BMP UTF-8 password, including the default SHA-1 PRF. + let password = "secret\u{1f512}"; + let private = pem::parse(include_bytes!( + "../../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("private key") + .into_contents(); + let salt = b"salt beyond the old fixed thirty-two byte representation"; + let iv = [7_u8; 16]; + let prfs = [ + (Hash::Sha1, "1.2.840.113549.2.7"), + (Hash::Sha224, "1.2.840.113549.2.8"), + (Hash::Sha256, "1.2.840.113549.2.9"), + (Hash::Sha384, "1.2.840.113549.2.10"), + (Hash::Sha512, "1.2.840.113549.2.11"), + ]; + for (cipher, cipher_oid) in [ + (Cipher::Aes128, pkcs8::pkcs5::pbes2::AES_128_CBC_OID), + (Cipher::Aes192, pkcs8::pkcs5::pbes2::AES_192_CBC_OID), + (Cipher::Aes256, pkcs8::pkcs5::pbes2::AES_256_CBC_OID), + ] { + for (hash, prf_oid) in prfs { + let mut key = Zeroizing::new([0_u8; 32]); + with_hash!( + hash, + D, + pbkdf2::pbkdf2_hmac::( + password.as_bytes(), + salt, + 2, + &mut key[..cipher.key_len()] + ) + ); + let mut output = vec![0; private.len() + 16]; + macro_rules! encrypt { + ($cipher:ty) => { + cbc::Encryptor::<$cipher>::new_from_slices(&key[..cipher.key_len()], &iv) + .expect("cipher") + .encrypt_padded_b2b::(&private, &mut output) + .expect("padding") + .to_vec() + }; + } + let ciphertext = match cipher { + Cipher::Aes128 => encrypt!(aes::Aes128Enc), + Cipher::Aes192 => encrypt!(aes::Aes192Enc), + Cipher::Aes256 => encrypt!(aes::Aes256Enc), + _ => unreachable!(), + }; + let mut params = vec![ + encoded(4, salt), + integer(2), + integer(cipher.key_len() as u16), + ]; + if !matches!(hash, Hash::Sha1) { + params.push(sequence(&[oid(Oid::new_unwrap(prf_oid)), encoded(5, &[])])); + } + let algorithm = sequence(&[ + oid(PBES2), + sequence(&[ + sequence(&[oid(PBKDF2), sequence(¶ms)]), + sequence(&[oid(cipher_oid), encoded(4, &iv)]), + ]), + ]); + let shrouded = bag( + pkcs12::PKCS_12_PKCS8_KEY_BAG_OID, + &sequence(&[algorithm, encoded(4, &ciphertext)]), + ); + let bytes = pfx(&[data(&sequence(&[shrouded]))]); + let limits = limits(64); + let contents = prepare(&bytes, &limits) + .expect("PBES2 preflight") + .decrypt(password) + .expect("UTF-8 PBES2 import"); + assert_eq!(&*contents.private_keys[0], &private); + } + } + } +} diff --git a/src/policy.rs b/src/policy.rs index afe9b87c..66b0baef 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -428,9 +428,9 @@ 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 PBKDF2 iterations or conservative scrypt work during key import. + /// Maximum aggregate PBKDF2/PKCS#12 hash rounds or conservative scrypt work during key import. pub max_key_import_kdf_work: usize, - /// Maximum estimated scrypt memory bytes during key import. + /// 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, diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index 60c2e276..3e5d2b89 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -1193,7 +1193,10 @@ fn projected_decoded_len_for_encoded_len(encoded_len: usize) -> usize { .unwrap_or(usize::MAX) } -fn validate_key_len(algorithm: DataEncryptionAlgorithm, key: &[u8]) -> Result<(), XmlEncError> { +pub(crate) fn validate_key_len( + algorithm: DataEncryptionAlgorithm, + key: &[u8], +) -> Result<(), XmlEncError> { if key.len() == algorithm.key_len() { Ok(()) } else { diff --git a/src/xmlenc/mod.rs b/src/xmlenc/mod.rs index c5474838..61288a63 100644 --- a/src/xmlenc/mod.rs +++ b/src/xmlenc/mod.rs @@ -15,6 +15,7 @@ use crate::xml::dom::Node; mod decrypt; +pub(crate) use decrypt::validate_key_len; mod encrypt; mod parse; mod types;