diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82109237..47c5bfd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,13 @@ jobs: fail-fast: false matrix: include: + - rust: stable + xml-backend: legacy-fat-runtime + cargo-args: --no-default-features --features xmldsig,xmlenc,c14n,xml-backends-all,legacy-algorithms + - rust: "1.92.0" + xml-backend: legacy-aws-fips + native-fips: true + cargo-args: --no-default-features --features aws-lc-fips,xmlenc,c14n,xml-backend-xmloxide,legacy-algorithms - rust: "1.92.0" xml-backend: aws-fips-xmloxide native-fips: true diff --git a/Cargo.toml b/Cargo.toml index bc00a288..dce13ded 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,7 +86,7 @@ snafu = { version = "0.9", optional = true } # Crypto aws-lc-rs = { version = "1.18.1", default-features = false, features = ["fips"], optional = true } -rsa = { package = "sad-rsa", version = "0.10.2", features = ["sha1", "sha2"], optional = true } +rsa = { package = "sad-rsa", version = "0.10.2", features = ["sha1", "sha2", "hazmat"], optional = true } sha1 = { version = "0.11", features = ["oid"], optional = true } sha2 = { version = "0.11", features = ["oid"], optional = true } sha3 = { version = "0.12", default-features = false, features = ["oid"], optional = true } @@ -117,7 +117,8 @@ aes-gcm = { version = "0.11.1", optional = true } aes-kw = { version = "0.3.1", optional = true } cbc = { version = "0.2.1", optional = true } des = { version = "0.9", optional = true } -md-5 = { version = "0.11", optional = true } +md-5 = { version = "0.11", features = ["oid"], optional = true } +ripemd = { version = "0.2.0", default-features = false, features = ["oid"], optional = true } pem = { version = "4", optional = true } pkcs12 = { version = "=0.2.0-pre.0", default-features = false, features = ["kdf"], optional = true } pbkdf2 = { version = "0.13", default-features = false, features = ["hmac"], optional = true } @@ -148,6 +149,7 @@ time = "0.3.55" tempfile = "3" [features] +legacy-algorithms = ["dep:ripemd", "dep:md-5", "dep:des"] default = ["std", "xmldsig", "xmlenc", "c14n", "xml-backend-xmloxide"] std = ["thiserror/std", "xmlparser/std"] aws-lc-fips = ["std", "xmldsig", "dep:aws-lc-rs"] diff --git a/LICENSE-THIRD-PARTY b/LICENSE-THIRD-PARTY index 98532d6c..f6efba2d 100644 --- a/LICENSE-THIRD-PARTY +++ b/LICENSE-THIRD-PARTY @@ -1,6 +1,33 @@ The xml-sec crate includes code derived from the following projects. Their copyright and permission notices are preserved below. +sad-rsa 0.10.2 (src/provider/rsa_pkcs1v15.rs) +------------------------------------------ + +Source: https://github.com/sadco-io/sad-rsa +Local fixed-width recovery adaptation: docs/rsa-recovery-patch.md + +Copyright (c) 2025-2026 Sadco Security Team +Copyright (c) 2015-2025 RustCrypto Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + RustCrypto ml-dsa 0.1.1 (src/rustcrypto_ml_dsa/) ------------------------------------------------ diff --git a/README.md b/README.md index bb991a81..defa2d2b 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ xml-sec = { version = "0.1", default-features = false, features = ["xmldsig", "c | Canonicalization | Canonical XML 1.0/1.1, Exclusive C14N, comments, and document subsets | | 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 | +| Optional compatibility algorithms | `legacy-algorithms`: MD5/RIPEMD-160 signatures and digests, AES-192, TripleDES-CBC/CMS wrap, and RSA-1.5 transport; library policy denies these by default | | X.509 | Certificate key extraction, chain validation, CRLs, required key authorization by default, and typed trust evidence | | 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 | diff --git a/compatibility/libxmlsec1-1.3.13-rules.json b/compatibility/libxmlsec1-1.3.13-rules.json index 4037c686..1a15f161 100644 --- a/compatibility/libxmlsec1-1.3.13-rules.json +++ b/compatibility/libxmlsec1-1.3.13-rules.json @@ -15,7 +15,7 @@ }, "legacy-policy-tests": { "test": "capability_ledger::legacy_algorithm_claims_are_policy_gated", - "description": "Legacy SHA-1 signature paths are available only through explicit compatibility policy opt-ins." + "description": "Legacy SHA-1 and optional historical mechanisms require explicit compatibility policy permission; optional methods also require the legacy-algorithms build feature." }, "sha1-verification-tests": { "test": "capability_ledger::legacy_algorithm_claims_are_policy_gated", @@ -79,6 +79,14 @@ "rationale": "The implementation retains this legacy algorithm for interoperability, but the compiled policy must explicitly permit it.", "evidence": "legacy-policy-tests" }, + { + "id": "feature-gated-legacy-uri", + "kinds": ["algorithm-uri"], + "name_regex": "^xmlSecHref(?:Aes192Cbc|Aes192Gcm|KWAes192|Des3Cbc|KWDes3|RsaPkcs1|Md5|Ripemd160|RsaMd5|RsaRipemd160|HmacMd5|HmacRipemd160|EcdsaRipemd160)$", + "outcome": "compatibility-profile-only", + "rationale": "The legacy-algorithms feature implements this mechanism through RustCrypto. Library defaults deny it; an explicit immutable algorithm allowlist is required. Other providers may report the capability unavailable without fallback.", + "evidence": "legacy-policy-tests" + }, { "id": "remaining-uri-surface", "kinds": ["algorithm-uri"], diff --git a/compatibility/libxmlsec1-1.3.13.json b/compatibility/libxmlsec1-1.3.13.json index 5bffbae5..edea8500 100644 --- a/compatibility/libxmlsec1-1.3.13.json +++ b/compatibility/libxmlsec1-1.3.13.json @@ -14,7 +14,7 @@ }, "legacy-policy-tests": { "test": "capability_ledger::legacy_algorithm_claims_are_policy_gated", - "description": "Legacy SHA-1 signature paths are available only through explicit compatibility policy opt-ins." + "description": "Legacy SHA-1 and optional historical mechanisms require explicit compatibility policy permission; optional methods also require the legacy-algorithms build feature." }, "native-algorithm-tests": { "test": "capability_ledger::native_algorithm_claims_match_the_rust_api", @@ -69,6 +69,11 @@ "rationale": "Deprecated C aliases are excluded until the compatibility layer has a concrete ABI contract.", "evidence": "unsupported-legacy-surface" }, + "feature-gated-legacy-uri": { + "outcome": "compatibility-profile-only", + "rationale": "The legacy-algorithms feature implements this mechanism through RustCrypto. Library defaults deny it; an explicit immutable algorithm allowlist is required. Other providers may report the capability unavailable without fallback.", + "evidence": "legacy-policy-tests" + }, "native-cli-behavior-options": { "outcome": "behavior-compatible", "rationale": "The native CLI implements this provider-independent option with donor-compatible parsing and command behavior.", @@ -4209,7 +4214,7 @@ "source": "src/strings.c", "line": 110, "detail": "http://www.w3.org/2001/04/xmlenc#aes192-cbc", - "classification": "remaining-uri-surface" + "classification": "feature-gated-legacy-uri" }, { "kind": "algorithm-uri", @@ -4217,7 +4222,7 @@ "source": "src/strings.c", "line": 119, "detail": "http://www.w3.org/2009/xmlenc11#aes192-gcm", - "classification": "remaining-uri-surface" + "classification": "feature-gated-legacy-uri" }, { "kind": "algorithm-uri", @@ -4393,7 +4398,7 @@ "source": "src/strings.c", "line": 254, "detail": "http://www.w3.org/2001/04/xmlenc#tripledes-cbc", - "classification": "remaining-uri-surface" + "classification": "feature-gated-legacy-uri" }, { "kind": "algorithm-uri", @@ -4441,7 +4446,7 @@ "source": "src/strings.c", "line": 390, "detail": "http://www.w3.org/2007/05/xmldsig-more#ecdsa-ripemd160", - "classification": "remaining-uri-surface" + "classification": "feature-gated-legacy-uri" }, { "kind": "algorithm-uri", @@ -4697,7 +4702,7 @@ "source": "src/strings.c", "line": 600, "detail": "http://www.w3.org/2001/04/xmldsig-more#hmac-md5", - "classification": "remaining-uri-surface" + "classification": "feature-gated-legacy-uri" }, { "kind": "algorithm-uri", @@ -4705,7 +4710,7 @@ "source": "src/strings.c", "line": 603, "detail": "http://www.w3.org/2001/04/xmldsig-more#hmac-ripemd160", - "classification": "remaining-uri-surface" + "classification": "feature-gated-legacy-uri" }, { "kind": "algorithm-uri", @@ -4761,7 +4766,7 @@ "source": "src/strings.c", "line": 128, "detail": "http://www.w3.org/2001/04/xmlenc#kw-aes192", - "classification": "remaining-uri-surface" + "classification": "feature-gated-legacy-uri" }, { "kind": "algorithm-uri", @@ -4801,7 +4806,7 @@ "source": "src/strings.c", "line": 257, "detail": "http://www.w3.org/2001/04/xmlenc#kw-tripledes", - "classification": "remaining-uri-surface" + "classification": "feature-gated-legacy-uri" }, { "kind": "algorithm-uri", @@ -4873,7 +4878,7 @@ "source": "src/strings.c", "line": 659, "detail": "http://www.w3.org/2001/04/xmldsig-more#md5", - "classification": "remaining-uri-surface" + "classification": "feature-gated-legacy-uri" }, { "kind": "algorithm-uri", @@ -4961,7 +4966,7 @@ "source": "src/strings.c", "line": 710, "detail": "http://www.w3.org/2001/04/xmlenc#ripemd160", - "classification": "remaining-uri-surface" + "classification": "feature-gated-legacy-uri" }, { "kind": "algorithm-uri", @@ -4969,7 +4974,7 @@ "source": "src/strings.c", "line": 725, "detail": "http://www.w3.org/2001/04/xmldsig-more#rsa-md5", - "classification": "remaining-uri-surface" + "classification": "feature-gated-legacy-uri" }, { "kind": "algorithm-uri", @@ -4993,7 +4998,7 @@ "source": "src/strings.c", "line": 746, "detail": "http://www.w3.org/2001/04/xmlenc#rsa-1_5", - "classification": "remaining-uri-surface" + "classification": "feature-gated-legacy-uri" }, { "kind": "algorithm-uri", @@ -5073,7 +5078,7 @@ "source": "src/strings.c", "line": 728, "detail": "http://www.w3.org/2001/04/xmldsig-more#rsa-ripemd160", - "classification": "remaining-uri-surface" + "classification": "feature-gated-legacy-uri" }, { "kind": "algorithm-uri", diff --git a/crates/xml-sec-xslt/src/serializer.rs b/crates/xml-sec-xslt/src/serializer.rs index 453efbfa..958b399c 100644 --- a/crates/xml-sec-xslt/src/serializer.rs +++ b/crates/xml-sec-xslt/src/serializer.rs @@ -435,8 +435,7 @@ fn render( // than imposing a must-not requirement. Pinned libxslt appends this final newline when // `indent` is omitted but suppresses it for explicit indent=no, so preserve that observable // compatibility distinction: https://www.w3.org/TR/1999/REC-xslt-19991116#output - if definition.method != OutputMethod::Text - && !(definition.indent_explicit && !definition.indent) + if definition.method != OutputMethod::Text && (!definition.indent_explicit || definition.indent) { text.push('\n'); } diff --git a/crates/xml-sec-xslt/src/xpath.rs b/crates/xml-sec-xslt/src/xpath.rs index 76a1f7c1..baa60f74 100644 --- a/crates/xml-sec-xslt/src/xpath.rs +++ b/crates/xml-sec-xslt/src/xpath.rs @@ -9329,7 +9329,7 @@ fn validate_decimal_subpattern( )); } } - if !has_digit && !(number.len() == 1 && saw_decimal) { + if !(has_digit || number.len() == 1 && saw_decimal) { return Err(invalid_decimal_pattern("subpattern has no digit")); } Ok(()) diff --git a/docs/cli.md b/docs/cli.md index 9fa7fc27..38278aee 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -144,6 +144,18 @@ legacy donor documents. This opt-in is local to the CLI. Library callers retain the secure defaults and must explicitly allow each legacy algorithm through their immutable signing or verification policy. +Building with `--features legacy-algorithms` extends that explicit CLI compatibility policy to +MD5/RIPEMD-160 signatures and reference digests, AES-192, TripleDES, and RSA-1.5 transport. +Use `--deskey[:NAME] FILE` for a 24-byte TripleDES content key and `--aeskey[:NAME] FILE` +for AES; equal key lengths do not make these types interchangeable. For `kw-aes192` or +`kw-tripledes` recipient templates, +the corresponding symmetric option supplies a KEK, and its name matches the recipient's +`KeyName`, not the generated content key. Symmetric key-store entries work through the same +wrap/unwrap path. RSA-1.5 is selected by the +recipient template's method URI and cannot carry OAEP parameters. `list-transforms` reports only +mechanisms available through the selected provider. Library defaults remain unchanged, and +selecting `--crypto aws-lc-fips` never falls back for an unavailable compatibility method. + Certificate and CRL signatures independently use the CLI's explicit `AllSupported` compatibility permission, including PQ methods when compiled. This does not grant trust: verification still requires caller-supplied anchors, valid paths and provider capability. diff --git a/docs/crypto-providers.md b/docs/crypto-providers.md index cf4262c6..d93a615e 100644 --- a/docs/crypto-providers.md +++ b/docs/crypto-providers.md @@ -32,6 +32,13 @@ Without it, that selection is an explicit unavailable-provider error. ## Mechanisms +`legacy-algorithms` adds RustCrypto MD5/RIPEMD-160 signatures and digests, AES-192 +content encryption and key wrap, TripleDES content encryption and CMS wrap, and RSA-1.5 +transport. All operations still go through `CryptoProvider` and require explicit library policy +permission. The AWS FIPS adapter reports these optional mechanisms unavailable; compiling both +providers never falls back to RustCrypto. This describes the adapter's supported operations, not +every primitive present in the underlying AWS-LC library or an approved-service claim. + The AWS provider supports SHA-1/SHA-2 and SHA3-256/384/512 digests; RSA PKCS#1 v1.5 signing with SHA-256/384/512 and verification with those digests or SHA-1; native ECDSA curve/digest combinations exposed by AWS-LC; AES-128/256 CBC and GCM; diff --git a/docs/rsa-recovery-patch.md b/docs/rsa-recovery-patch.md new file mode 100644 index 00000000..450ca09f --- /dev/null +++ b/docs/rsa-recovery-patch.md @@ -0,0 +1,33 @@ +# Fixed-Width RSA Recovery Adaptation + +`src/provider/rsa_pkcs1v15.rs` adapts the PKCS#1 v1.5 padding recovery of +[`sad-rsa` 0.10.2](https://github.com/sadco-io/sad-rsa), specifically +`src/pkcs1v15.rs::decrypt` and `src/algorithms/pkcs1v15.rs::decrypt_inner`. +The existing dependency remains responsible for RSA arithmetic, blinding and fault checking +through its `hazmat::rsa_decrypt_and_check` primitive. No second RSA engine is introduced. + +The general-message donor API implements implicit rejection but discards padding validity. +That API cannot safely decide whether an enclosing unauthenticated CBC content operation +may return plaintext: fallback bytes may accidentally satisfy CBC padding. The local adaptation +returns an opaque fixed-width candidate with the original constant-time validity mask. +The operation performs content work first, then rejects an invalid recovery and zeroizes any +successful fallback plaintext. Key-ring wrappers preserve the opaque candidate. + +[RFC 8017 §7.2.2](https://www.rfc-editor.org/rfc/rfc8017#section-7.2.2) defines the encoding +checks. Since the CEK length is known from the content algorithm, its delimiter position is +public. A single linear padding scan replaces the donor's variable-length alignment buffers. +All width-correct ciphertexts, including representatives at least as large as the modulus, +perform blinded RSA; out-of-range values are normalized but keep an invalid mask. Operational +RNG or arithmetic failures still fail closed. Secret decoded integers, encoded blocks and +candidate bytes are zeroized on drop. + +This is a narrow embedded adaptation, not a whole dependency fork or a separately published +crate. It remains in the single `xml-sec` package. Updating `sad-rsa` requires checking the +named donor functions and its arithmetic API against this adaptation. Regression tests cover +all supported CEK lengths, header/delimiter/nonzero-padding checks, out-of-range values, and +successful CBC output following invalid recovery. + +The mask does not make CBC authenticated or claim comprehensive padding-oracle resistance. +[XMLEnc 1.1 §6.1.2](https://www.w3.org/TR/2013/REC-xmlenc-core1-20130411/#sec-bleichenbacher-attack) +specifically warns that random-key fallback does not defeat CBC-based chosen-ciphertext attacks. +Prefer RSA-OAEP with authenticated content; legacy CBC requires external authentication. diff --git a/docs/xmldsig.md b/docs/xmldsig.md index aa6b5f5d..3e29b3ae 100644 --- a/docs/xmldsig.md +++ b/docs/xmldsig.md @@ -435,6 +435,17 @@ reported as an invalid per-reference result without changing core `SignedInfo` v ## Current Scope +The optional `legacy-algorithms` feature adds MD5 and RIPEMD-160 reference digests, +RSA-MD5/RSA-RIPEMD160, HMAC-MD5/HMAC-RIPEMD160, and ECDSA-RIPEMD160. Secure library +defaults reject these methods even when compiled. Explicitly allow signature methods through +`VerificationPolicy::signature_algorithms` / `SigningPolicy::signature_algorithms` and reference +digests independently through `digest_algorithms`. Permitting a signature never permits its +reference digest implicitly. The same digest policy gates `X509Digest` certificate selectors, +including embedded certificates, configured lookup certificates, `KeyInfoReference`, and +`RetrievalMethod`, before selector hashing. ECDSA-RIPEMD160 retains independent curve selection. +These are +historical interoperability mechanisms, not recommended algorithms for new documents. + Implemented signature methods include DSA-SHA1/SHA256; HMAC-SHA1/SHA224/SHA256/SHA384/SHA512; RSA PKCS#1 v1.5 with SHA-1/SHA224/SHA256/SHA384/SHA512; and ECDSA SHA-1/SHA224/SHA256/SHA384/SHA512 and SHA3-224/256/384/512. ECDSA selects P-256, P-384, or P-521 diff --git a/docs/xmlenc.md b/docs/xmlenc.md index 738fda67..5dbeee3b 100644 --- a/docs/xmlenc.md +++ b/docs/xmlenc.md @@ -143,12 +143,42 @@ inherited from its ancestors. Their `_and_backend` variants retain an explicitly backend while revalidating the containing document; the shorter entry points use the build default. `PrivateKeyDecryptor` unwraps embedded RSA-OAEP `EncryptedKey` values and `KekDecryptor` -unwraps AES-KW values. RSA PKCS#1 v1.5 transport, `CipherReference`, and unauthenticated external -resource loading are rejected; only inline `CipherValue` is accepted. Encryption plaintext, +unwraps AES-KW values. With `legacy-algorithms`, these resolvers additionally support parameterless +RSA PKCS#1 v1.5 transport, AES-192 key wrap, and RFC 3217 TripleDES key wrap. `CipherReference` +and unauthenticated external resource loading are rejected; only inline `CipherValue` is accepted. Encryption plaintext, recipient counts, and the complete serialized `EncryptedData` fragment are bounded. Decryption applies the same aggregate recipient ceiling while parsing, bounds each retained identifier, algorithm URI, key name, OAEP label, and reference URI, and rechecks caller-constructed `EncryptedData` before decoding or key resolution. + +## Optional Compatibility Mechanisms + +The `legacy-algorithms` feature compiles AES-192-CBC/GCM, TripleDES-CBC, AES-192-KW, +TripleDES CMS wrap, and RSA-1.5 transport. Compilation is not permission: explicitly put each +required method into `EncryptionPolicy` and `DecryptionPolicy`'s `data_algorithms`, +`key_wrap_algorithms`, or `key_transport_algorithms` allowlist. Default operations reject all of +these optional methods. AES-192 is included for compatibility completeness, not because its key +size is cryptographically weak. TripleDES and RSA-1.5 should be restricted to required legacy +integrations; prefer authenticated AES-GCM and RSA-OAEP for new output. + +Use `EncryptionRecipient::rsa_pkcs1v15` (or `provider_pkcs1v15`) for RSA-1.5, not +`rsa_oaep` with altered parameters. XMLEnc 1.1 §5.5.1 defines no OAEP children for this method; +the parser rejects them. Fixed-width recovery retains its padding-validity mask alongside a +zeroized content-key candidate; invalid padding selects a random fallback key. Content decryption +runs before the operation checks that mask, and an invalid recovery can never release plaintext, +even when CBC padding happens to succeed. This does not authenticate CBC or eliminate its +chosen-ciphertext risks (XMLEnc 1.1 §6.1.2); use CBC only with a separately authenticated boundary. +`DecryptionKeyResolver` wrappers must forward `resolve_content_keys_with_policy` to preserve +this state. The low-level byte-returning resolver APIs reject invalid recovery rather than +exposing fallback key bytes. See [the RSA recovery adaptation](rsa-recovery-patch.md). +TripleDES wrapping follows RFC 3217 §§2–3, including its SHA-1 checksum and fixed outer IV; +it does not reinterpret arbitrary AES key bytes as DES parity-normalized bytes. +`SymmetricKeyDecryptor::new` and `KekDecryptor::new` bind keys to AES, irrespective of length. For TripleDES, explicitly +use `with_kind(key, SymmetricKeyKind::Des)`; the family is trusted request input, never inferred +from an untrusted algorithm URI. Borrowed KEKs use `KekDecryptor::borrowed_with_kind` to retain +the same family boundary without copying key material. Both families may supply CLI KEKs through `--aes-key:name`, +`--des-key:name`, or symmetric `--keys-file` entries when the template contains a corresponding +key-wrap method. Key names identify the wrapping recipient, not the generated content key. That typed-input check validates the top-level content `EncryptionMethod` and every embedded key method before resolver dispatch, and bounds both encoded and projected decoded `CipherValue` sizes. Callers therefore cannot bypass parser structural or allocation limits by constructing the diff --git a/scripts/install-xmlsec1.sh b/scripts/install-xmlsec1.sh index 93ba4f59..f6f170a2 100755 --- a/scripts/install-xmlsec1.sh +++ b/scripts/install-xmlsec1.sh @@ -36,9 +36,14 @@ xmlsec_output() { xmlsec_version_output() { xmlsec_output --version; } xmlsec_required_capabilities() { - [[ "$modern_oracle" == 1 ]] || return 0 local output transform output="$(xmlsec_output --list-transforms)" || return 1 + # Historical algorithms are disabled by default upstream. The test oracle + # must expose them to independently validate our optional compatibility path. + for transform in md5 ripemd160 rsa-md5 rsa-ripemd160 hmac-md5 hmac-ripemd160 ecdsa-ripemd160; do + [[ "$output" == *"\"$transform\""* ]] || return 1 + done + [[ "$modern_oracle" == 1 ]] || return 0 for transform in eddsa-ed25519 eddsa-ed25519ctx eddsa-ed25519ph eddsa-ed448 eddsa-ed448ph; do [[ "$output" == *"\"$transform\""* ]] || return 1 done @@ -179,6 +184,8 @@ OBJ_DIR="$build_dir" "$source_dir/autogen.sh" \ --disable-static \ --without-gnutls \ --without-nss \ + --enable-md5 \ + --enable-ripemd160 \ "$openssl_option" make --directory "$build_dir" --jobs "$build_jobs" make --directory "$build_dir" install DESTDIR="$stage_dir" @@ -199,7 +206,7 @@ if ! xmlsec_version_is_expected "$version_output"; then exit 1 fi if ! xmlsec_required_capabilities; then - printf 'xmlsec1 oracle is missing required EdDSA transforms\n' >&2 + printf 'xmlsec1 oracle is missing required compatibility or EdDSA transforms\n' >&2 exit 1 fi printf '%s\n' "$version_output" diff --git a/src/key_manager.rs b/src/key_manager.rs index 5ccd520d..81bbdb80 100644 --- a/src/key_manager.rs +++ b/src/key_manager.rs @@ -148,10 +148,38 @@ pub enum SymmetricKeyKind { Hmac, /// AES content-encryption key. Aes, - /// Legacy DES key marker; import rejects it until a DES operation exists. + /// Three-key TDEA key, available with `legacy-algorithms`. Des, } +impl SymmetricKeyKind { + /// Whether the compiled mechanism supports this content-key family. + pub fn is_encryption_key(self) -> bool { + self == Self::Aes || (cfg!(feature = "legacy-algorithms") && self == Self::Des) + } + + #[cfg(feature = "xmlenc")] + fn accepts_algorithm(self, algorithm: crate::xmlenc::DataEncryptionAlgorithm) -> bool { + #[cfg(feature = "legacy-algorithms")] + if algorithm == crate::xmlenc::DataEncryptionAlgorithm::TripleDesCbc { + return self == Self::Des; + } + #[cfg(not(feature = "legacy-algorithms"))] + let _ = algorithm; + self == Self::Aes + } +} + +fn des_key_usages(bytes: &[u8]) -> Result { + if cfg!(feature = "legacy-algorithms") && bytes.len() == 24 { + Ok(KeyUsages::ENCRYPT.union(KeyUsages::DECRYPT)) + } else { + Err(KeyStoreError::Selection( + "unsupported DES key length or mechanism", + )) + } +} + #[derive(Default)] /// A caller-owned inventory of imported XML Security key material. pub struct KeyInventory { @@ -702,21 +730,24 @@ enum ParsedMaterial { type ParsedDsaKey = (KeyValueInfo, Option>>); #[cfg(feature = "xmlenc")] -struct InventoryDirectAes(Zeroizing>); +struct InventoryDirectSymmetric { + bytes: Zeroizing>, + kind: SymmetricKeyKind, +} #[cfg(feature = "xmlenc")] -impl crate::xmlenc::DecryptionKeyResolver for InventoryDirectAes { +impl crate::xmlenc::DecryptionKeyResolver for InventoryDirectSymmetric { 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() { + if encrypted_key.is_some() || !self.kind.accepts_algorithm(algorithm) { return Err(crate::xmlenc::XmlEncError::KeyNotFound); } - crate::xmlenc::validate_key_len(algorithm, &self.0)?; - Ok(self.0.to_vec()) + crate::xmlenc::validate_key_len(algorithm, &self.bytes)?; + Ok(self.bytes.to_vec()) } fn resolve_key_candidates( @@ -748,12 +779,12 @@ impl crate::xmlenc::DecryptionKeyResolver for InventoryDirectAes { if encrypted_key.is_some() { return Err(crate::xmlenc::XmlEncError::KeyNotFound); } - check_selected_material_size(self.0.len(), &policy.resources).map_err( - |error| match error { + check_selected_material_size(self.bytes.len(), &policy.resources).map_err(|error| { + match error { KeyStoreError::Policy(violation) => crate::xmlenc::XmlEncError::Policy(violation), other => crate::xmlenc::XmlEncError::InvalidStructure(other.to_string()), - }, - )?; + } + })?; self.resolve_key_candidates(provider, algorithm, None, budget) } } @@ -1088,15 +1119,16 @@ impl KeyInventory { &mut visited, |entry| &entry.name, )? { - if entry.kind != SymmetricKeyKind::Aes || !entry.usages.allows(KeyUsage::Decrypt) { + if !entry.kind.is_encryption_key() || !entry.usages.allows(KeyUsage::Decrypt) { return Err(KeyStoreError::Selection( "key is not authorized for decryption", )); } check_selected_material_size(entry.bytes.len(), &policy.resources)?; - return Ok(Box::new(InventoryDirectAes(Zeroizing::new( - entry.bytes.to_vec(), - )))); + return Ok(Box::new(InventoryDirectSymmetric { + bytes: Zeroizing::new(entry.bytes.to_vec()), + kind: entry.kind, + })); } let entry = find_named_entry( &self.private_keys, @@ -1160,9 +1192,7 @@ impl KeyInventory { 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")); - } + SymmetricKeyKind::Des => des_key_usages(&bytes)?, }; if usages.0 == 0 || usages.0 & !permitted.0 != 0 { return Err(KeyStoreError::Selection( @@ -2149,9 +2179,7 @@ impl KeyInventory { 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")); - } + SymmetricKeyKind::Des => des_key_usages(&bytes)?, }; store.symmetric_keys.push(StoredSymmetricKey { name, @@ -4178,6 +4206,26 @@ mod tests { ); } + #[cfg(feature = "legacy-algorithms")] + #[test] + fn xml_store_imports_triple_des_without_granting_operation_permission() { + // A supported key encoding grants usage, not algorithm policy permission; + // an eight-byte DES key must never be mistaken for a three-key TDEA key. + let encoded = base64::engine::general_purpose::STANDARD.encode([0x31; 24]); + let xml = format!( + "des{encoded}" + ); + let store = KeyInventory::from_xml_bytes( + xml.as_bytes(), + &xml_policy(ResourcePolicy::default()), + XmlBackend::default(), + ) + .expect("a three-key TDEA inventory entry is supported"); + assert_eq!(store.symmetric_keys()[0].kind, SymmetricKeyKind::Des); + assert_eq!(store.symmetric_keys()[0].bytes.as_slice(), &[0x31; 24]); + assert!(store.symmetric_keys()[0].usages.allows(KeyUsage::Decrypt)); + } + #[test] fn xml_store_rejects_unusable_public_key_values() { // Malformed supported public-key material must fail at import, not at verification. diff --git a/src/policy.rs b/src/policy.rs index 2c330063..fb1e3091 100644 --- a/src/policy.rs +++ b/src/policy.rs @@ -1048,7 +1048,9 @@ impl KeyTrustPolicy { if !permitted { return Err(PolicyViolation::Algorithm { operation: "certificate/CRL verification", - algorithm: algorithm.oid().to_owned(), + algorithm: algorithm + .oid() + .map_or_else(|| format!("{algorithm:?}"), str::to_owned), }); } Ok(()) @@ -1131,6 +1133,25 @@ pub struct VerificationPolicy { #[cfg(feature = "xmldsig")] impl VerificationPolicy { + pub(crate) fn check_digest_algorithm( + &self, + algorithm: DigestAlgorithm, + ) -> Result<(), PolicyViolation> { + if self + .digest_algorithms + .as_ref() + .map_or(!algorithm.requires_explicit_permission(), |allowed| { + allowed.contains(&algorithm) + }) + { + Ok(()) + } else { + Err(PolicyViolation::Algorithm { + operation: "verification", + algorithm: algorithm.uri().to_owned(), + }) + } + } /// Validate the complete snapshot against implementation hard ceilings. pub fn validate(&self) -> Result<(), PolicyViolation> { self.resources.validate()?; @@ -1143,7 +1164,8 @@ impl VerificationPolicy { &self, algorithm: SignatureAlgorithm, ) -> Result<(), PolicyViolation> { - if matches!(algorithm, SignatureAlgorithm::PostQuantum(_)) + if (matches!(algorithm, SignatureAlgorithm::PostQuantum(_)) + || algorithm.requires_explicit_permission()) && !self .signature_algorithms .as_ref() @@ -1265,6 +1287,25 @@ fn check_signing_algorithm( } } +/// Enforce the shared content-cipher permission contract before dispatch. +#[cfg(feature = "xmlenc")] +pub(crate) fn check_content_algorithm( + allowlist: Option<&HashSet>, + algorithm: DataEncryptionAlgorithm, + operation: &'static str, +) -> Result<(), PolicyViolation> { + if allowlist.map_or(!algorithm.requires_explicit_permission(), |allowed| { + allowed.contains(&algorithm) + }) { + Ok(()) + } else { + Err(PolicyViolation::Algorithm { + operation, + algorithm: algorithm.uri().to_owned(), + }) + } +} + /// Immutable policy snapshot for XMLEnc encryption. #[cfg(feature = "xmlenc")] #[derive(Debug, Clone, Default)] @@ -1328,6 +1369,45 @@ impl DecryptionPolicy { mod tests { use super::*; + #[cfg(all(feature = "legacy-algorithms", feature = "xmldsig"))] + #[test] + fn legacy_digest_permission_is_explicit_and_operation_local() { + // Capability does not imply acceptance, and permission for one digest + // or operation must never enable another digest or operation. + for algorithm in [DigestAlgorithm::Md5, DigestAlgorithm::Ripemd160] { + assert!( + VerificationPolicy::default() + .check_digest_algorithm(algorithm) + .is_err() + ); + assert!( + SigningPolicy::default() + .check_digest_algorithm(algorithm) + .is_err() + ); + let verification = VerificationPolicy { + digest_algorithms: Some(HashSet::from([algorithm])), + ..VerificationPolicy::default() + }; + let signing = SigningPolicy { + digest_algorithms: Some(HashSet::from([algorithm])), + ..SigningPolicy::default() + }; + assert!(verification.check_digest_algorithm(algorithm).is_ok()); + assert!(signing.check_digest_algorithm(algorithm).is_ok()); + assert!( + verification + .check_digest_algorithm(DigestAlgorithm::Sha256) + .is_err() + ); + assert!( + signing + .check_digest_algorithm(DigestAlgorithm::Sha256) + .is_err() + ); + } + } + #[cfg(feature = "xmldsig")] #[test] fn certificate_permissions_keep_exact_pss_and_compatibility_distinct() { diff --git a/src/provider.rs b/src/provider.rs index 9ee38d4c..d8ad553a 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -7,6 +7,60 @@ #[cfg(feature = "aws-lc-fips")] mod aws_lc; +#[cfg(all(feature = "xmlenc", feature = "legacy-algorithms"))] +mod rsa_pkcs1v15; + +/// Secret candidate whose recovery validity is retained until content work ends. +/// Debug output never exposes key bytes or padding validity. +#[cfg(feature = "xmlenc")] +pub struct RecoveredContentKey { + bytes: zeroize::Zeroizing>, + valid: subtle::Choice, +} + +#[cfg(feature = "xmlenc")] +impl std::fmt::Debug for RecoveredContentKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RecoveredContentKey") + .finish_non_exhaustive() + } +} + +#[cfg(feature = "xmlenc")] +impl RecoveredContentKey { + /// An explicitly supplied key, or one recovered by an integrity-checking + /// mechanism such as OAEP or key wrap. Not for implicit-rejection output. + pub fn confirmed(bytes: Vec) -> Self { + Self { + bytes: zeroize::Zeroizing::new(bytes), + valid: subtle::Choice::from(1), + } + } + + #[cfg(feature = "legacy-algorithms")] + /// Preserve a trusted provider's constant-time padding-validation result. + /// `bytes` must already contain the fixed-width real-or-fallback candidate; + /// the caller must not branch on `valid` during recovery. The enclosing + /// content operation consumes this state only after primitive decryption. + pub fn recovery(bytes: zeroize::Zeroizing>, valid: subtle::Choice) -> Self { + Self { bytes, valid } + } + + pub(crate) fn bytes(&self) -> &[u8] { + &self.bytes + } + pub(crate) fn valid(&self) -> bool { + bool::from(self.valid) + } + /// Consume a completed recovery outside a content operation. Content + /// operations must retain the candidate until after primitive decryption. + pub fn into_key(mut self) -> Result, ProviderError> { + if !self.valid() { + return Err(ProviderError::AuthenticationFailed); + } + Ok(core::mem::take(&mut *self.bytes)) + } +} #[cfg(all(feature = "aws-lc-fips", feature = "xmlenc"))] pub use aws_lc::AwsLcRsaPrivateKey; #[cfg(feature = "aws-lc-fips")] @@ -99,6 +153,12 @@ pub enum ProviderCapability<'a> { /// RSA-OAEP key recovery with complete digest, MGF, and label parameters. #[cfg(feature = "xmlenc")] KeyRecovery(&'a RsaOaepParameters), + /// Historical RSAES-PKCS1-v1_5 transport, without OAEP parameters. + #[cfg(all(feature = "xmlenc", feature = "legacy-algorithms"))] + Pkcs1v15Transport, + /// Historical RSAES-PKCS1-v1_5 fixed-length content-key recovery. + #[cfg(all(feature = "xmlenc", feature = "legacy-algorithms"))] + Pkcs1v15Recovery, /// Provider-defined key agreement identified by its standard URI. KeyAgreement(&'a KeyAgreementParameters<'a>), /// Provider-defined key derivation identified by its standard URI. @@ -132,6 +192,10 @@ impl ProviderCapability<'_> { Self::KeyTransport(_) => ProviderOperation::KeyTransport, #[cfg(feature = "xmlenc")] Self::KeyRecovery(_) => ProviderOperation::KeyRecovery, + #[cfg(all(feature = "xmlenc", feature = "legacy-algorithms"))] + Self::Pkcs1v15Transport => ProviderOperation::KeyTransport, + #[cfg(all(feature = "xmlenc", feature = "legacy-algorithms"))] + Self::Pkcs1v15Recovery => ProviderOperation::KeyRecovery, Self::KeyAgreement(_) => ProviderOperation::KeyAgreement, Self::Kdf(_) => ProviderOperation::Kdf, Self::Random => ProviderOperation::Random, @@ -147,7 +211,7 @@ impl ProviderCapability<'_> { #[cfg(feature = "xmldsig")] Self::Sign(algorithm) | Self::Verify(algorithm) => Some(algorithm.uri()), #[cfg(feature = "xmldsig")] - Self::VerifyCertificate(algorithm) => Some(algorithm.oid()), + Self::VerifyCertificate(algorithm) => algorithm.oid(), #[cfg(feature = "xmlenc")] Self::Encrypt(algorithm) | Self::Decrypt(algorithm) => Some(algorithm.uri()), #[cfg(feature = "xmlenc")] @@ -157,6 +221,10 @@ impl ProviderCapability<'_> { Some(parameters.algorithm.uri()) } Self::KeyAgreement(parameters) => Some(parameters.algorithm), + #[cfg(all(feature = "xmlenc", feature = "legacy-algorithms"))] + Self::Pkcs1v15Transport | Self::Pkcs1v15Recovery => { + Some(crate::xmlenc::KeyTransportAlgorithm::RsaPkcs1v15.uri()) + } Self::Kdf(parameters) => Some(parameters.algorithm), Self::Random => None, } @@ -219,10 +287,14 @@ pub enum X509SignatureAlgorithm { #[cfg(feature = "xmldsig")] impl X509SignatureAlgorithm { - /// Return the standard AlgorithmIdentifier OID used for capability queries. + /// Return the assigned AlgorithmIdentifier OID, or `None` for unsupported combinations. #[must_use] - pub const fn oid(self) -> &'static str { - match self { + pub const fn oid(self) -> Option<&'static str> { + Some(match self { + #[cfg(feature = "legacy-algorithms")] + Self::Dsa(DigestAlgorithm::Md5 | DigestAlgorithm::Ripemd160) + | Self::RsaPkcs1v15(DigestAlgorithm::Md5 | DigestAlgorithm::Ripemd160) + | Self::Ecdsa(DigestAlgorithm::Md5 | DigestAlgorithm::Ripemd160) => return None, Self::Dsa(DigestAlgorithm::Sha1) => "1.2.840.10040.4.3", Self::Dsa(DigestAlgorithm::Sha224) => "2.16.840.1.101.3.4.3.1", Self::Dsa(DigestAlgorithm::Sha256) => "2.16.840.1.101.3.4.3.2", @@ -254,7 +326,7 @@ impl X509SignatureAlgorithm { Self::Ed25519 => "1.3.101.112", Self::Ed448 => "1.3.101.113", Self::PostQuantum(algorithm) => algorithm.oid(), - } + }) } } @@ -352,6 +424,22 @@ pub enum ProviderError { /// and output framing; it cannot recover a backend-specific key object. #[cfg(feature = "xmlenc")] pub trait KeyTransportKey: Send + Sync { + /// Execute historical transport; unsupported opaque handles never fall back. + #[cfg(feature = "legacy-algorithms")] + fn transport_pkcs1v15( + &self, + _provider: &dyn CryptoProvider, + _plaintext: &[u8], + ) -> Result, ProviderError> { + Err(ProviderError::Unsupported { + operation: ProviderOperation::KeyTransport, + algorithm: Some( + crate::xmlenc::KeyTransportAlgorithm::RsaPkcs1v15 + .uri() + .into(), + ), + }) + } /// RSA modulus bytes without redundant leading zero octets. /// /// These components must identify the exact key used by @@ -377,6 +465,23 @@ pub trait KeyTransportKey: Send + Sync { /// public metadata required to reject malformed RSA inputs before dispatch. #[cfg(feature = "xmlenc")] pub trait KeyRecoveryKey: Send + Sync { + /// Recover a content key of the caller's already validated algorithm width. + #[cfg(feature = "legacy-algorithms")] + fn recover_pkcs1v15( + &self, + _provider: &dyn CryptoProvider, + _ciphertext: &[u8], + _key_len: usize, + ) -> Result { + Err(ProviderError::Unsupported { + operation: ProviderOperation::KeyRecovery, + algorithm: Some( + crate::xmlenc::KeyTransportAlgorithm::RsaPkcs1v15 + .uri() + .into(), + ), + }) + } /// Native engine binding; an incompatible provider must reject this handle. fn provider_name(&self) -> Option<&'static str> { None @@ -534,7 +639,7 @@ pub trait CryptoProvider: Send + Sync { let _ = (signed_data, signature, issuer_spki_der); Err(ProviderError::Unsupported { operation: ProviderOperation::VerifyCertificate, - algorithm: Some(algorithm.oid().to_owned()), + algorithm: algorithm.oid().map(str::to_owned), }) } @@ -596,6 +701,41 @@ pub trait CryptoProvider: Send + Sync { ciphertext: &[u8], ) -> Result, ProviderError>; + /// Transport using RSAES-PKCS1-v1_5; not an OAEP configuration. + #[cfg(all(feature = "xmlenc", feature = "legacy-algorithms"))] + fn transport_pkcs1v15( + &self, + _key: &dyn KeyTransportKey, + _plaintext: &[u8], + ) -> Result, ProviderError> { + Err(ProviderError::Unsupported { + operation: ProviderOperation::KeyTransport, + algorithm: Some( + crate::xmlenc::KeyTransportAlgorithm::RsaPkcs1v15 + .uri() + .into(), + ), + }) + } + + /// Recover a fixed-width content key without exposing padding validity. + #[cfg(all(feature = "xmlenc", feature = "legacy-algorithms"))] + fn recover_pkcs1v15( + &self, + _key: &dyn KeyRecoveryKey, + _ciphertext: &[u8], + _key_len: usize, + ) -> Result { + Err(ProviderError::Unsupported { + operation: ProviderOperation::KeyRecovery, + algorithm: Some( + crate::xmlenc::KeyTransportAlgorithm::RsaPkcs1v15 + .uri() + .into(), + ), + }) + } + /// Perform key agreement with an opaque provider-owned private key. fn agree_key( &self, @@ -668,6 +808,14 @@ impl From for RustCryptoRsaPublicKey { #[cfg(feature = "xmlenc")] impl KeyTransportKey for RustCryptoRsaPublicKey { + #[cfg(feature = "legacy-algorithms")] + fn transport_pkcs1v15( + &self, + provider: &dyn CryptoProvider, + plaintext: &[u8], + ) -> Result, ProviderError> { + rustcrypto::transport_pkcs1v15(provider, &self.key, plaintext) + } fn rsa_modulus(&self) -> Cow<'_, [u8]> { Cow::Borrowed(&self.modulus) } @@ -688,6 +836,14 @@ impl KeyTransportKey for RustCryptoRsaPublicKey { #[cfg(feature = "xmlenc")] impl KeyTransportKey for rsa::RsaPublicKey { + #[cfg(feature = "legacy-algorithms")] + fn transport_pkcs1v15( + &self, + provider: &dyn CryptoProvider, + plaintext: &[u8], + ) -> Result, ProviderError> { + rustcrypto::transport_pkcs1v15(provider, self, plaintext) + } fn rsa_modulus(&self) -> Cow<'_, [u8]> { use rsa::traits::PublicKeyParts as _; Cow::Owned(self.n().to_be_bytes_trimmed_vartime().into_vec()) @@ -739,6 +895,15 @@ impl From for RustCryptoRsaPrivateKey { #[cfg(feature = "xmlenc")] impl KeyRecoveryKey for RustCryptoRsaPrivateKey { + #[cfg(feature = "legacy-algorithms")] + fn recover_pkcs1v15( + &self, + provider: &dyn CryptoProvider, + ciphertext: &[u8], + key_len: usize, + ) -> Result { + rsa_pkcs1v15::recover(provider, &self.key, ciphertext, key_len) + } fn rsa_modulus_bits(&self) -> usize { KeyRecoveryKey::rsa_modulus_bits(&self.key) } @@ -761,6 +926,15 @@ impl KeyRecoveryKey for RustCryptoRsaPrivateKey { #[cfg(feature = "xmlenc")] impl KeyRecoveryKey for rsa::RsaPrivateKey { + #[cfg(feature = "legacy-algorithms")] + fn recover_pkcs1v15( + &self, + provider: &dyn CryptoProvider, + ciphertext: &[u8], + key_len: usize, + ) -> Result { + rsa_pkcs1v15::recover(provider, self, ciphertext, key_len) + } fn rsa_modulus_bits(&self) -> usize { use rsa::traits::PublicKeyParts as _; self.n().bits_vartime() as usize @@ -834,6 +1008,26 @@ impl TryRng for ProviderRng<'_> { impl TryCryptoRng for ProviderRng<'_> {} impl CryptoProvider for RustCryptoProvider { + #[cfg(all(feature = "xmlenc", feature = "legacy-algorithms"))] + fn transport_pkcs1v15( + &self, + key: &dyn KeyTransportKey, + plaintext: &[u8], + ) -> Result, ProviderError> { + self.require_capability(ProviderCapability::Pkcs1v15Transport)?; + key.transport_pkcs1v15(self, plaintext) + } + + #[cfg(all(feature = "xmlenc", feature = "legacy-algorithms"))] + fn recover_pkcs1v15( + &self, + key: &dyn KeyRecoveryKey, + ciphertext: &[u8], + key_len: usize, + ) -> Result { + self.require_capability(ProviderCapability::Pkcs1v15Recovery)?; + key.recover_pkcs1v15(self, ciphertext, key_len) + } fn name(&self) -> &'static str { "rustcrypto" } @@ -867,19 +1061,9 @@ impl CryptoProvider for RustCryptoProvider { A::Ed25519 | A::Ed25519Ctx | A::Ed25519Ph | A::Ed448 | A::Ed448Ph => { Box::new(EdDsaSigningKey::from_pkcs8_der(algorithm, der)?) } - A::RsaSha1 | A::RsaSha224 | A::RsaSha256 | A::RsaSha384 | A::RsaSha512 => { - Box::new(RsaSigningKey::from_pkcs8_der(der)?) - } + method if method.is_rsa() => Box::new(RsaSigningKey::from_pkcs8_der(der)?), A::DsaSha1 | A::DsaSha256 => Box::new(DsaSigningKey::from_pkcs8_der(der)?), - A::EcdsaSha1 - | A::EcdsaSha224 - | A::EcdsaSha256 - | A::EcdsaSha384 - | A::EcdsaSha512 - | A::EcdsaSha3_224 - | A::EcdsaSha3_256 - | A::EcdsaSha3_384 - | A::EcdsaSha3_512 => { + method if method.ecdsa_digest().is_some() => { if let Ok(key) = EcdsaP256SigningKey::from_pkcs8_der(der) { Box::new(key) } else if let Ok(key) = EcdsaP384SigningKey::from_pkcs8_der(der) { @@ -919,6 +1103,8 @@ impl CryptoProvider for RustCryptoProvider { ProviderCapability::KeyWrap(_) | ProviderCapability::KeyUnwrap(_) => true, #[cfg(feature = "xmlenc")] ProviderCapability::KeyTransport(_) | ProviderCapability::KeyRecovery(_) => true, + #[cfg(all(feature = "xmlenc", feature = "legacy-algorithms"))] + ProviderCapability::Pkcs1v15Transport | ProviderCapability::Pkcs1v15Recovery => true, ProviderCapability::Random => true, ProviderCapability::KeyAgreement(_) | ProviderCapability::Kdf(_) => false, } @@ -948,6 +1134,10 @@ impl CryptoProvider for RustCryptoProvider { use sha2::{Digest, Sha224, Sha256, Sha384, Sha512}; use sha3::{Sha3_224, Sha3_256, Sha3_384, Sha3_512}; Ok(match algorithm { + #[cfg(feature = "legacy-algorithms")] + DigestAlgorithm::Md5 => md5::Md5::digest(data).to_vec(), + #[cfg(feature = "legacy-algorithms")] + DigestAlgorithm::Ripemd160 => ripemd::Ripemd160::digest(data).to_vec(), DigestAlgorithm::Sha1 => Sha1::digest(data).to_vec(), DigestAlgorithm::Sha224 => Sha224::digest(data).to_vec(), DigestAlgorithm::Sha256 => Sha256::digest(data).to_vec(), @@ -1046,7 +1236,7 @@ impl CryptoProvider for RustCryptoProvider { kek: &[u8], key: &[u8], ) -> Result, ProviderError> { - rustcrypto::wrap_key(algorithm, kek, key) + rustcrypto::wrap_key(self, algorithm, kek, key) } #[cfg(feature = "xmlenc")] @@ -1088,7 +1278,7 @@ fn is_supported_x509_signature(algorithm: X509SignatureAlgorithm) -> bool { X509SignatureAlgorithm::Dsa(DigestAlgorithm::Sha1) | X509SignatureAlgorithm::Ed25519 | X509SignatureAlgorithm::Ed448 => true, - X509SignatureAlgorithm::Ecdsa(_) => true, + X509SignatureAlgorithm::Ecdsa(digest) => rustcrypto_x509::ecdsa_algorithm(digest).is_some(), X509SignatureAlgorithm::PostQuantum(_) => cfg!(feature = "experimental-pq"), X509SignatureAlgorithm::RsaPkcs1v15(digest) => matches!( digest, @@ -1381,8 +1571,10 @@ mod rustcrypto_x509 { } } - const fn ecdsa_algorithm(digest: DigestAlgorithm) -> Option { + pub(super) const fn ecdsa_algorithm(digest: DigestAlgorithm) -> Option { match digest { + #[cfg(feature = "legacy-algorithms")] + DigestAlgorithm::Md5 | DigestAlgorithm::Ripemd160 => None, DigestAlgorithm::Sha1 => Some(SignatureAlgorithm::EcdsaSha1), DigestAlgorithm::Sha224 => Some(SignatureAlgorithm::EcdsaSha224), DigestAlgorithm::Sha256 => Some(SignatureAlgorithm::EcdsaSha256), @@ -1398,13 +1590,15 @@ mod rustcrypto_x509 { fn unsupported(algorithm: X509SignatureAlgorithm) -> Result { Err(ProviderError::Unsupported { operation: super::ProviderOperation::VerifyCertificate, - algorithm: Some(algorithm.oid().to_owned()), + algorithm: algorithm.oid().map(str::to_owned), }) } } #[cfg(feature = "xmlenc")] mod rustcrypto { + #[cfg(feature = "legacy-algorithms")] + use aes::Aes192; use aes::{ Aes128, Aes256, cipher::{BlockModeDecrypt, BlockModeEncrypt, KeyIvInit, block_padding::NoPadding}, @@ -1415,6 +1609,8 @@ mod rustcrypto { }; use aes_kw::{KwAes128, KwAes256}; use cbc::{Decryptor, Encryptor}; + #[cfg(feature = "legacy-algorithms")] + use des::TdesEde3; use rsa::{Oaep, traits::PaddingScheme}; use sha1::Sha1; use sha2::{Sha256, Sha384, Sha512}; @@ -1432,6 +1628,16 @@ mod rustcrypto { ) -> Result, ProviderError> { check_key(algorithm.key_len(), key)?; match algorithm { + #[cfg(feature = "legacy-algorithms")] + DataEncryptionAlgorithm::TripleDesCbc => { + encrypt_cbc::(provider, key, plaintext) + } + #[cfg(feature = "legacy-algorithms")] + DataEncryptionAlgorithm::Aes192Cbc => encrypt_cbc::(provider, key, plaintext), + #[cfg(feature = "legacy-algorithms")] + DataEncryptionAlgorithm::Aes192Gcm => encrypt_gcm::< + aes_gcm::AesGcm, + >(provider, key, plaintext), DataEncryptionAlgorithm::Aes128Cbc => encrypt_cbc::(provider, key, plaintext), DataEncryptionAlgorithm::Aes256Cbc => encrypt_cbc::(provider, key, plaintext), DataEncryptionAlgorithm::Aes128Gcm => { @@ -1450,6 +1656,14 @@ mod rustcrypto { ) -> Result, ProviderError> { check_key(algorithm.key_len(), key)?; match algorithm { + #[cfg(feature = "legacy-algorithms")] + DataEncryptionAlgorithm::TripleDesCbc => decrypt_cbc::(key, ciphertext), + #[cfg(feature = "legacy-algorithms")] + DataEncryptionAlgorithm::Aes192Cbc => decrypt_cbc::(key, ciphertext), + #[cfg(feature = "legacy-algorithms")] + DataEncryptionAlgorithm::Aes192Gcm => { + decrypt_gcm::>(key, ciphertext) + } DataEncryptionAlgorithm::Aes128Cbc => decrypt_cbc::(key, ciphertext), DataEncryptionAlgorithm::Aes256Cbc => decrypt_cbc::(key, ciphertext), DataEncryptionAlgorithm::Aes128Gcm => decrypt_gcm::(key, ciphertext), @@ -1476,43 +1690,49 @@ mod rustcrypto { where C: aes::cipher::BlockCipherEncrypt + aes::cipher::KeyInit, { - let mut iv = [0_u8; 16]; - provider.fill_random(&mut iv)?; - let pad_len = 16 - (plaintext.len() % 16); - let mut padded = vec![0_u8; plaintext.len() + pad_len]; + let block = C::block_size(); + let pad_len = block - (plaintext.len() % block); + let length = plaintext + .len() + .checked_add(pad_len) + .and_then(|len| len.checked_add(block)) + .ok_or(ProviderError::InvalidInput( + ProviderInputError::AesCbcFraming, + ))?; + let mut output = zeroize::Zeroizing::new(vec![0_u8; length]); + let (iv, padded) = output.split_at_mut(block); + provider.fill_random(iv)?; padded[..plaintext.len()].copy_from_slice(plaintext); if pad_len > 1 { let last = padded.len() - 1; provider.fill_random(&mut padded[plaintext.len()..last])?; } *padded.last_mut().expect("padding is non-empty") = pad_len as u8; - Encryptor::::new_from_slices(key, &iv) + Encryptor::::new_from_slices(key, iv) .map_err(|_| { ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization("AES-CBC")) })? - .encrypt_padded::(&mut padded, plaintext.len() + pad_len) + .encrypt_padded::(padded, plaintext.len() + pad_len) .map_err(|_| { ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization( "AES-CBC padding", )) })?; - let mut output = Vec::with_capacity(16 + padded.len()); - output.extend_from_slice(&iv); - output.extend_from_slice(&padded); - Ok(output) + Ok(core::mem::take(&mut *output)) } fn decrypt_cbc(key: &[u8], ciphertext: &[u8]) -> Result, ProviderError> where C: aes::cipher::BlockCipherDecrypt + aes::cipher::KeyInit, { - if ciphertext.len() < 32 || !(ciphertext.len() - 16).is_multiple_of(16) { + let block = C::block_size(); + if ciphertext.len() < 2 * block || !(ciphertext.len() - block).is_multiple_of(block) { return Err(ProviderError::InvalidInput( ProviderInputError::AesCbcFraming, )); } - let (iv, body) = ciphertext.split_at(16); - let mut plaintext = body.to_vec(); + let (iv, body) = ciphertext.split_at(block); + let mut plaintext = zeroize::Zeroizing::new(body.to_vec()); Decryptor::::new_from_slices(key, iv) .map_err(|_| { ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization("AES-CBC")) @@ -1523,13 +1743,14 @@ mod rustcrypto { ProviderInputError::AesCbcCiphertext, ))?; let padding_bytes = usize::from(pad_len); - if !(1..=16).contains(&padding_bytes) || padding_bytes > plaintext.len() { + if !(1..=block).contains(&padding_bytes) || padding_bytes > plaintext.len() { return Err(ProviderError::InvalidInput( ProviderInputError::AesCbcCiphertext, )); } - plaintext.truncate(plaintext.len() - padding_bytes); - Ok(plaintext) + let length = plaintext.len() - padding_bytes; + plaintext.truncate(length); + Ok(core::mem::take(&mut *plaintext)) } fn encrypt_gcm( @@ -1545,19 +1766,27 @@ mod rustcrypto { let cipher = C::new_from_slice(key).map_err(|_| { ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization("AES-GCM")) })?; - let mut output = plaintext.to_vec(); + let length = plaintext + .len() + .checked_add(28) + .ok_or(ProviderError::InvalidInput( + ProviderInputError::PrimitiveInitialization("AES-GCM length"), + ))?; + // Encrypt directly in the final nonce-prefixed allocation rather than + // copying an intermediate ciphertext into another document-sized Vec. + let mut output = zeroize::Zeroizing::new(Vec::with_capacity(length)); + output.extend_from_slice(&nonce); + output.extend_from_slice(plaintext); let nonce = Nonce::try_from(nonce.as_slice()).map_err(|_| { ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization( "AES-GCM nonce", )) })?; - cipher - .encrypt_in_place(&nonce, &[], &mut output) + let tag = cipher + .encrypt_inout_detached(&nonce, &[], output[12..].as_mut().into()) .map_err(|_| ProviderError::AuthenticationFailed)?; - let mut framed = Vec::with_capacity(12 + output.len()); - framed.extend_from_slice(&nonce); - framed.extend_from_slice(&output); - Ok(framed) + output.extend_from_slice(&tag); + Ok(core::mem::take(&mut *output)) } fn decrypt_gcm(key: &[u8], ciphertext: &[u8]) -> Result, ProviderError> @@ -1573,26 +1802,57 @@ mod rustcrypto { let cipher = C::new_from_slice(key).map_err(|_| { ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization("AES-GCM")) })?; - let mut plaintext = body.to_vec(); + let mut plaintext = zeroize::Zeroizing::new(body.to_vec()); let nonce = Nonce::try_from(nonce).map_err(|_| { ProviderError::InvalidInput(ProviderInputError::PrimitiveInitialization( "AES-GCM nonce", )) })?; cipher - .decrypt_in_place(&nonce, &[], &mut plaintext) + .decrypt_in_place(&nonce, &[], &mut *plaintext) .map_err(|_| ProviderError::AuthenticationFailed)?; - Ok(plaintext) + Ok(core::mem::take(&mut *plaintext)) } pub(super) fn wrap_key( + provider: &dyn CryptoProvider, algorithm: KeyWrapAlgorithm, kek: &[u8], key: &[u8], ) -> Result, ProviderError> { check_key(algorithm.key_len(), kek)?; - let mut output = vec![0_u8; key.len() + 8]; + #[cfg(feature = "legacy-algorithms")] + if algorithm == KeyWrapAlgorithm::TripleDes { + return wrap_des3(provider, kek, key); + } + #[cfg(not(feature = "legacy-algorithms"))] + let _ = provider; + // RFC 3394 §2.2.1 requires at least two 64-bit plaintext blocks. + // Reject before allocating: the primitive also accepts the one-block + // degenerate case, which is not this XML Encryption algorithm. + // https://www.rfc-editor.org/rfc/rfc3394.html#section-2.2.1 + if key.len() < 16 || !key.len().is_multiple_of(8) { + return Err(ProviderError::InvalidInput( + ProviderInputError::AesKeyWrapFraming, + )); + } + let length = + key.len() + .checked_add(algorithm.overhead()) + .ok_or(ProviderError::InvalidInput( + ProviderInputError::AesKeyWrapFraming, + ))?; + let mut output = vec![0_u8; length]; match algorithm { + #[cfg(feature = "legacy-algorithms")] + KeyWrapAlgorithm::AesKw192 => aes_kw::KwAes192::new_from_slice(kek) + .map_err(|_| ProviderError::InvalidKeySize { + expected: 24, + actual: kek.len(), + })? + .wrap_key(key, &mut output), + #[cfg(feature = "legacy-algorithms")] + KeyWrapAlgorithm::TripleDes => unreachable!("CMS wrapping dispatched before AES"), KeyWrapAlgorithm::AesKw128 => KwAes128::new_from_slice(kek) .map_err(|_| ProviderError::InvalidKeySize { expected: 16, @@ -1616,13 +1876,26 @@ mod rustcrypto { wrapped: &[u8], ) -> Result, ProviderError> { check_key(algorithm.key_len(), kek)?; - if wrapped.len() < 16 || !wrapped.len().is_multiple_of(8) { + #[cfg(feature = "legacy-algorithms")] + if algorithm == KeyWrapAlgorithm::TripleDes { + return unwrap_des3(kek, wrapped); + } + if wrapped.len() < 24 || !wrapped.len().is_multiple_of(8) { return Err(ProviderError::InvalidInput( ProviderInputError::AesKeyWrapFraming, )); } - let mut output = vec![0_u8; wrapped.len() - 8]; - let key = match algorithm { + let mut output = zeroize::Zeroizing::new(vec![0_u8; wrapped.len() - 8]); + match algorithm { + #[cfg(feature = "legacy-algorithms")] + KeyWrapAlgorithm::AesKw192 => aes_kw::KwAes192::new_from_slice(kek) + .map_err(|_| ProviderError::InvalidKeySize { + expected: 24, + actual: kek.len(), + })? + .unwrap_key(wrapped, &mut output), + #[cfg(feature = "legacy-algorithms")] + KeyWrapAlgorithm::TripleDes => unreachable!("CMS unwrapping dispatched before AES"), KeyWrapAlgorithm::AesKw128 => KwAes128::new_from_slice(kek) .map_err(|_| ProviderError::InvalidKeySize { expected: 16, @@ -1637,7 +1910,94 @@ mod rustcrypto { .unwrap_key(wrapped, &mut output), } .map_err(|_| ProviderError::AuthenticationFailed)?; - Ok(key.to_vec()) + Ok(core::mem::take(&mut *output)) + } + + #[cfg(feature = "legacy-algorithms")] + const CMS_IV: [u8; 8] = [0x4a, 0xdd, 0xa2, 0x2c, 0x79, 0xe8, 0x21, 0x05]; + + #[cfg(feature = "legacy-algorithms")] + fn wrap_des3( + provider: &dyn CryptoProvider, + kek: &[u8], + key: &[u8], + ) -> Result, ProviderError> { + use sha1::Digest as _; + // XMLEnc 1.1 §5.7.1 permits other key types in addition to RFC 3217's + // DES CEK. Preserve opaque key octets (especially AES); generated DES + // content keys are normalized to odd parity at their generation boundary. + // https://www.w3.org/TR/xmlenc-core1/#sec-CMS-3DES + if key.len() < 16 || !key.len().is_multiple_of(8) { + return Err(ProviderError::InvalidInput( + ProviderInputError::AesKeyWrapFraming, + )); + } + let length = key + .len() + .checked_add(16) + .ok_or(ProviderError::InvalidInput( + ProviderInputError::AesKeyWrapFraming, + ))?; + let mut output = zeroize::Zeroizing::new(vec![0; length]); + provider.fill_random(&mut output[..8])?; + output[8..8 + key.len()].copy_from_slice(key); + output[8 + key.len()..].copy_from_slice(&Sha1::digest(key)[..8]); + let (iv, body) = output.split_at_mut(8); + let body_len = body.len(); + Encryptor::::new_from_slices(kek, iv) + .map_err(|_| ProviderError::AuthenticationFailed)? + .encrypt_padded::(body, body_len) + .map_err(|_| ProviderError::AuthenticationFailed)?; + output.reverse(); + Encryptor::::new_from_slices(kek, &CMS_IV) + .map_err(|_| ProviderError::AuthenticationFailed)? + .encrypt_padded::(&mut output, length) + .map_err(|_| ProviderError::AuthenticationFailed)?; + Ok(core::mem::take(&mut *output)) + } + + #[cfg(feature = "legacy-algorithms")] + fn unwrap_des3(kek: &[u8], wrapped: &[u8]) -> Result, ProviderError> { + use sha1::Digest as _; + use subtle::ConstantTimeEq as _; + // RFC 3217 §3.2: undo the outer CBC, octet reversal and inner CBC, + // then authenticate the checksum before exposing any recovered bytes. + // https://www.rfc-editor.org/rfc/rfc3217#section-3.2 + if wrapped.len() < 32 || !wrapped.len().is_multiple_of(8) { + return Err(ProviderError::InvalidInput( + ProviderInputError::AesKeyWrapFraming, + )); + } + let mut output = zeroize::Zeroizing::new(wrapped.to_vec()); + Decryptor::::new_from_slices(kek, &CMS_IV) + .map_err(|_| ProviderError::AuthenticationFailed)? + .decrypt_padded::(&mut output) + .map_err(|_| ProviderError::AuthenticationFailed)?; + output.reverse(); + let (iv, body) = output.split_at_mut(8); + Decryptor::::new_from_slices(kek, iv) + .map_err(|_| ProviderError::AuthenticationFailed)? + .decrypt_padded::(body) + .map_err(|_| ProviderError::AuthenticationFailed)?; + let key_len = body.len() - 8; + if !bool::from(Sha1::digest(&body[..key_len])[..8].ct_eq(&body[key_len..])) { + return Err(ProviderError::AuthenticationFailed); + } + output.copy_within(8..8 + key_len, 0); + zeroize::Zeroize::zeroize(&mut output[key_len..]); + output.truncate(key_len); + Ok(core::mem::take(&mut *output)) + } + + #[cfg(feature = "legacy-algorithms")] + pub(super) fn transport_pkcs1v15( + provider: &dyn CryptoProvider, + key: &rsa::RsaPublicKey, + plaintext: &[u8], + ) -> Result, ProviderError> { + rsa::Pkcs1v15Encrypt + .encrypt(&mut super::ProviderRng(provider), key, plaintext) + .map_err(map_rsa_error) } pub(super) fn transport_key( @@ -1646,6 +2006,11 @@ mod rustcrypto { parameters: &RsaOaepParameters, plaintext: &[u8], ) -> Result, ProviderError> { + if parameters.algorithm.requires_explicit_permission() { + return Err(ProviderError::InvalidInput( + ProviderInputError::PrimitiveInitialization("RSA-OAEP algorithm"), + )); + } let mut rng = super::ProviderRng(provider); macro_rules! encrypt_with { ($digest:ty, $mgf:ty) => { @@ -1712,6 +2077,11 @@ mod rustcrypto { parameters: &RsaOaepParameters, ciphertext: &[u8], ) -> Result, ProviderError> { + if parameters.algorithm.requires_explicit_permission() { + return Err(ProviderError::InvalidInput( + ProviderInputError::PrimitiveInitialization("RSA-OAEP algorithm"), + )); + } let mut rng = super::ProviderRng(provider); macro_rules! decrypt_with { ($digest:ty, $mgf:ty) => { @@ -1772,9 +2142,9 @@ mod rustcrypto { result.map_err(map_rsa_error) } - fn map_rsa_error(error: rsa::Error) -> ProviderError { + pub(super) fn map_rsa_error(error: rsa::Error) -> ProviderError { match error { - rsa::Error::Rng => ProviderError::Random("RSA-OAEP randomness failed".into()), + rsa::Error::Rng => ProviderError::Random("RSA randomness failed".into()), _ => ProviderError::AuthenticationFailed, } } @@ -1788,6 +2158,154 @@ mod tests { use super::*; + #[cfg(all(feature = "legacy-algorithms", feature = "xmlenc"))] + #[test] + fn legacy_ciphers_cover_empty_and_block_boundary_plaintexts() { + // Each cipher's own framing, including DES's 8-byte blocks, must agree + // with the public size preflight for empty and non-aligned messages. + for algorithm in [ + DataEncryptionAlgorithm::TripleDesCbc, + DataEncryptionAlgorithm::Aes192Cbc, + DataEncryptionAlgorithm::Aes192Gcm, + ] { + let key = [0x31; 24]; + for length in [0, 1, 7, 8, 15, 16, 17, 31, 32, 33] { + let plaintext = vec![0x41; length]; + let ciphertext = RUST_CRYPTO_PROVIDER + .encrypt_data(algorithm, &key, &plaintext) + .expect("valid legacy encryption must succeed"); + assert_eq!( + Some(ciphertext.len()), + algorithm.ciphertext_len_for_plaintext(length) + ); + assert_eq!( + RUST_CRYPTO_PROVIDER + .decrypt_data(algorithm, &key, &ciphertext) + .expect("matching legacy key must recover plaintext"), + plaintext + ); + assert!( + RUST_CRYPTO_PROVIDER + .encrypt_data(algorithm, &key[..23], &plaintext) + .is_err() + ); + assert!( + RUST_CRYPTO_PROVIDER + .decrypt_data(algorithm, &key[..23], &ciphertext) + .is_err() + ); + assert!( + RUST_CRYPTO_PROVIDER + .decrypt_data(algorithm, &key, &ciphertext[..ciphertext.len() - 1]) + .is_err() + ); + if algorithm == DataEncryptionAlgorithm::Aes192Gcm { + let mut corrupted = ciphertext; + corrupted[12] ^= 1; + assert!(matches!( + RUST_CRYPTO_PROVIDER.decrypt_data(algorithm, &key, &corrupted), + Err(ProviderError::AuthenticationFailed) + )); + } + } + } + } + + #[cfg(all(feature = "legacy-algorithms", feature = "xmlenc"))] + #[test] + fn cms_triple_des_unwrap_matches_rfc3217_vector() { + // RFC 3217 §3.4 independently validates both CBC passes and reversal; + // a round-trip alone could hide matching errors in wrap and unwrap. + // https://www.rfc-editor.org/rfc/rfc3217.html#section-3.4 + let kek = hex_literal::hex!("255e0d1c07b646dfb3134cc843ba8aa71f025b7c0838251f"); + let key = hex_literal::hex!("2923bf85e06dd6ae529149f1f1bae9eab3a7da3d860d3e98"); + let wrapped = hex_literal::hex!( + "690107618ef092b3b48ca1796b234ae9fa33ebb4159604037db5d6a84eb3aac2768c632775a467d4" + ); + assert_eq!( + RUST_CRYPTO_PROVIDER + .unwrap_key(KeyWrapAlgorithm::TripleDes, &kek, &wrapped) + .expect("RFC 3217 vector must authenticate"), + key + ); + for offset in 0..wrapped.len() { + let mut corrupted = wrapped; + corrupted[offset] ^= 1; + assert!(matches!( + RUST_CRYPTO_PROVIDER.unwrap_key(KeyWrapAlgorithm::TripleDes, &kek, &corrupted), + Err(ProviderError::AuthenticationFailed) + )); + } + } + + #[cfg(all(feature = "legacy-algorithms", feature = "xmlenc"))] + #[test] + fn legacy_wrap_preserves_opaque_key_bytes_and_checks_integrity() { + // XMLEnc permits wrapping non-DES keys; parity normalization must not + // corrupt opaque AES key octets. Each algorithm has its own overhead. + for algorithm in [KeyWrapAlgorithm::AesKw192, KeyWrapAlgorithm::TripleDes] { + let kek = [0x37; 24]; + for length in [16, 24, 32] { + let key: Vec<_> = (0..length).map(|byte| byte as u8).collect(); + let mut wrapped = RUST_CRYPTO_PROVIDER + .wrap_key(algorithm, &kek, &key) + .expect("valid content key must wrap"); + assert_eq!(wrapped.len(), key.len() + algorithm.overhead()); + assert_eq!( + RUST_CRYPTO_PROVIDER + .unwrap_key(algorithm, &kek, &wrapped) + .expect("matching KEK must recover exact key octets"), + key + ); + assert!( + RUST_CRYPTO_PROVIDER + .unwrap_key(algorithm, &[0x38; 24], &wrapped) + .is_err() + ); + wrapped[0] ^= 1; + assert!( + RUST_CRYPTO_PROVIDER + .unwrap_key(algorithm, &kek, &wrapped) + .is_err() + ); + } + for length in [0, 1, 8, 15, 17] { + assert!( + RUST_CRYPTO_PROVIDER + .wrap_key(algorithm, &kek, &vec![0; length]) + .is_err() + ); + } + } + } + + #[cfg(all(feature = "legacy-algorithms", feature = "xmldsig"))] + #[test] + fn legacy_digests_match_known_answers() { + // Published empty/abc answers prevent URI wiring from silently choosing + // a different hash with the same output width. + for (algorithm, expected) in [ + ( + DigestAlgorithm::Md5, + &hex_literal::hex!("900150983cd24fb0d6963f7d28e17f72")[..], + ), + ( + DigestAlgorithm::Ripemd160, + &hex_literal::hex!("8eb208f7e05d987a9b044a8e98c6b087f15a0bfc")[..], + ), + ] { + assert_eq!( + RUST_CRYPTO_PROVIDER + .digest(algorithm, b"abc") + .expect("compiled digest must be supported"), + expected + ); + assert_eq!(DigestAlgorithm::from_uri(algorithm.uri()), Some(algorithm)); + assert!(algorithm.requires_explicit_permission()); + assert!(!algorithm.signing_allowed()); + } + } + #[cfg(feature = "xmldsig")] struct CountingRandomProvider { random_calls: AtomicUsize, @@ -1942,6 +2460,16 @@ mod tests { )) ); } + // Certificate capabilities must agree with the actual execution + // mapping, not merely with the presence of an ECDSA primitive. + #[cfg(feature = "legacy-algorithms")] + for digest in [DigestAlgorithm::Md5, DigestAlgorithm::Ripemd160] { + assert!( + !RUST_CRYPTO_PROVIDER.supports(ProviderCapability::VerifyCertificate( + X509SignatureAlgorithm::Ecdsa(digest) + )) + ); + } } #[cfg(feature = "xmldsig")] diff --git a/src/provider/aws_lc.rs b/src/provider/aws_lc.rs index 205ce285..366de60a 100644 --- a/src/provider/aws_lc.rs +++ b/src/provider/aws_lc.rs @@ -41,6 +41,8 @@ impl AwsLcFipsProvider { fn digest_algorithm(algorithm: DigestAlgorithm) -> Option<&'static digest::Algorithm> { Some(match algorithm { + #[cfg(feature = "legacy-algorithms")] + DigestAlgorithm::Md5 | DigestAlgorithm::Ripemd160 => return None, DigestAlgorithm::Sha1 => &digest::SHA1_FOR_LEGACY_USE_ONLY, DigestAlgorithm::Sha224 => &digest::SHA224, DigestAlgorithm::Sha256 => &digest::SHA256, @@ -100,10 +102,22 @@ impl CryptoProvider for AwsLcFipsProvider { certificate_method(algorithm).is_some() } #[cfg(feature = "xmlenc")] - ProviderCapability::Encrypt(_) - | ProviderCapability::Decrypt(_) - | ProviderCapability::KeyWrap(_) - | ProviderCapability::KeyUnwrap(_) => true, + ProviderCapability::Encrypt(algorithm) | ProviderCapability::Decrypt(algorithm) => { + matches!( + algorithm, + DataEncryptionAlgorithm::Aes128Cbc + | DataEncryptionAlgorithm::Aes256Cbc + | DataEncryptionAlgorithm::Aes128Gcm + | DataEncryptionAlgorithm::Aes256Gcm + ) + } + #[cfg(feature = "xmlenc")] + ProviderCapability::KeyWrap(algorithm) | ProviderCapability::KeyUnwrap(algorithm) => { + matches!( + algorithm, + KeyWrapAlgorithm::AesKw128 | KeyWrapAlgorithm::AesKw256 + ) + } #[cfg(feature = "xmlenc")] ProviderCapability::KeyTransport(parameters) | ProviderCapability::KeyRecovery(parameters) => oaep_algorithm(parameters).is_some(), @@ -201,8 +215,15 @@ impl CryptoProvider for AwsLcFipsProvider { key: &[u8], plaintext: &[u8], ) -> Result, ProviderError> { + self.require_capability(ProviderCapability::Encrypt(algorithm))?; check_key(algorithm.key_len(), key)?; match algorithm { + #[cfg(feature = "legacy-algorithms")] + DataEncryptionAlgorithm::TripleDesCbc + | DataEncryptionAlgorithm::Aes192Cbc + | DataEncryptionAlgorithm::Aes192Gcm => { + Err(unsupported(ProviderCapability::Encrypt(algorithm))) + } DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc => { let padding = 16 - plaintext.len() % 16; let mut output = vec![0; framed_len(plaintext.len(), 16 + padding)?]; @@ -254,8 +275,15 @@ impl CryptoProvider for AwsLcFipsProvider { key: &[u8], ciphertext: &[u8], ) -> Result, ProviderError> { + self.require_capability(ProviderCapability::Decrypt(algorithm))?; check_key(algorithm.key_len(), key)?; match algorithm { + #[cfg(feature = "legacy-algorithms")] + DataEncryptionAlgorithm::TripleDesCbc + | DataEncryptionAlgorithm::Aes192Cbc + | DataEncryptionAlgorithm::Aes192Gcm => { + Err(unsupported(ProviderCapability::Decrypt(algorithm))) + } DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc => { if ciphertext.len() < 32 || !ciphertext.len().is_multiple_of(16) { return Err(ProviderError::InvalidInput( @@ -311,6 +339,7 @@ impl CryptoProvider for AwsLcFipsProvider { kek: &[u8], key: &[u8], ) -> Result, ProviderError> { + self.require_capability(ProviderCapability::KeyWrap(algorithm))?; check_key(algorithm.key_len(), kek)?; if key.len() < 16 || !key.len().is_multiple_of(8) || key.len() > i32::MAX as usize - 8 { return Err(ProviderError::InvalidInput( @@ -331,6 +360,7 @@ impl CryptoProvider for AwsLcFipsProvider { kek: &[u8], wrapped: &[u8], ) -> Result, ProviderError> { + self.require_capability(ProviderCapability::KeyUnwrap(algorithm))?; check_key(algorithm.key_len(), kek)?; if wrapped.len() < 24 || !wrapped.len().is_multiple_of(8) @@ -464,6 +494,10 @@ fn wrapping_key( kek: &[u8], ) -> Result, ProviderError> { let cipher = match algorithm { + #[cfg(feature = "legacy-algorithms")] + KeyWrapAlgorithm::AesKw192 | KeyWrapAlgorithm::TripleDes => { + return Err(unsupported(ProviderCapability::KeyWrap(algorithm))); + } KeyWrapAlgorithm::AesKw128 => &key_wrap::AES_128, KeyWrapAlgorithm::AesKw256 => &key_wrap::AES_256, }; @@ -878,6 +912,40 @@ mod tests { asn1::{BitStringRef, UintRef}, }; + #[cfg(all(feature = "legacy-algorithms", feature = "xmlenc"))] + #[test] + fn optional_legacy_capabilities_fail_without_another_engine() { + // Compiling RustCrypto compatibility mechanisms cannot make AWS native + // capability checks silently dispatch into that different provider. + for algorithm in [ + DataEncryptionAlgorithm::TripleDesCbc, + DataEncryptionAlgorithm::Aes192Cbc, + DataEncryptionAlgorithm::Aes192Gcm, + ] { + assert!(!AwsLcFipsProvider.supports(ProviderCapability::Encrypt(algorithm))); + assert!(matches!( + AwsLcFipsProvider.encrypt_data(algorithm, &[0x31; 24], b"test"), + Err(ProviderError::Unsupported { .. }) + )); + } + for algorithm in [KeyWrapAlgorithm::AesKw192, KeyWrapAlgorithm::TripleDes] { + assert!(!AwsLcFipsProvider.supports(ProviderCapability::KeyWrap(algorithm))); + assert!(matches!( + AwsLcFipsProvider.wrap_key(algorithm, &[0x31; 24], &[0x42; 16]), + Err(ProviderError::Unsupported { .. }) + )); + } + assert!(!AwsLcFipsProvider.supports(ProviderCapability::Pkcs1v15Transport)); + assert!(!AwsLcFipsProvider.supports(ProviderCapability::Pkcs1v15Recovery)); + for digest in [DigestAlgorithm::Md5, DigestAlgorithm::Ripemd160] { + assert!(!AwsLcFipsProvider.supports(ProviderCapability::Digest(digest))); + assert!(matches!( + AwsLcFipsProvider.digest(digest, b"test"), + Err(ProviderError::Unsupported { .. }) + )); + } + } + #[test] fn rsa_verifier_checks_exact_bit_boundaries_without_bigints() { // Byte-aligned rounding would accept 2047 or 8193 bits. Exercise the diff --git a/src/provider/rsa_pkcs1v15.rs b/src/provider/rsa_pkcs1v15.rs new file mode 100644 index 00000000..0ec89a6b --- /dev/null +++ b/src/provider/rsa_pkcs1v15.rs @@ -0,0 +1,133 @@ +//! Fixed-width session-key adaptation of sad-rsa 0.10.2 PKCS#1 v1.5 recovery. +//! +//! Source: sadco-io/sad-rsa, src/pkcs1v15.rs and +//! src/algorithms/pkcs1v15.rs::decrypt_inner (MIT OR Apache-2.0). +//! RSA arithmetic, blinding and fault checking remain in that dependency. +//! Unlike its general-message API, this adapter retains padding validity for +//! the enclosing content operation and never exposes a variable-length result. + +use crypto_bigint::{BoxedUint, CtAssign, CtLt}; +use rsa::{RsaPrivateKey, traits::PublicKeyParts}; +use subtle::{Choice, ConditionallySelectable, ConstantTimeEq}; +use zeroize::Zeroizing; + +use super::{CryptoProvider, ProviderError, ProviderInputError, ProviderRng, RecoveredContentKey}; + +pub(super) fn recover( + provider: &dyn CryptoProvider, + key: &RsaPrivateKey, + ciphertext: &[u8], + key_len: usize, +) -> Result { + let width = key.size(); + if !matches!(key_len, 16 | 24 | 32) || width < key_len + 11 { + return Err(ProviderError::InvalidInput( + ProviderInputError::PrimitiveInitialization("XMLEnc content-key width"), + )); + } + if ciphertext.len() != width { + return Err(ProviderError::InvalidInput( + ProviderInputError::PrimitiveInitialization("RSA ciphertext width"), + )); + } + let mut output = Zeroizing::new(vec![0; key_len]); + provider.fill_random(&mut output)?; + let mut representative = BoxedUint::from_be_slice(ciphertext, key.n_bits_precision()) + .map_err(|error| super::rustcrypto::map_rsa_error(error.into()))?; + let in_range = representative.ct_lt(key.n()); + // Normalize an out-of-range public representative, but retain its invalidity. + // Every width-correct input still performs blinded RSA and content work. + representative.ct_assign( + &BoxedUint::zero_with_precision(key.n_bits_precision()), + !in_range, + ); + let decoded = Zeroizing::new( + rsa::hazmat::rsa_decrypt_and_check(key, Some(&mut ProviderRng(provider)), &representative) + .map_err(super::rustcrypto::map_rsa_error)?, + ); + let encoded = Zeroizing::new(decoded.to_be_bytes()); + let encoded = &encoded[encoded.len() - width..]; + // RFC 8017 §7.2.2 Step 3: 00 || 02 || at least eight nonzero PS bytes + // || 00 || M. Expected CEK width makes the delimiter position public, + // avoiding the donor's variable-length alignment/materialization entirely. + // https://www.rfc-editor.org/rfc/rfc8017#section-7.2.2 + let delimiter = width - key_len - 1; + let mut valid = encoded[0].ct_eq(&0) + & encoded[1].ct_eq(&2) + & encoded[delimiter].ct_eq(&0) + & Choice::from(in_range.to_u8()); + for byte in &encoded[2..delimiter] { + valid &= !byte.ct_eq(&0); + } + for (output, recovered) in output.iter_mut().zip(&encoded[delimiter + 1..]) { + *output = u8::conditional_select(output, recovered, valid); + } + Ok(RecoveredContentKey::recovery(output, valid)) +} + +#[cfg(test)] +mod tests { + use super::*; + use rsa::pkcs8::DecodePrivateKey as _; + + fn private_key() -> RsaPrivateKey { + RsaPrivateKey::from_pkcs8_pem(include_str!( + "../../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("valid RSA fixture") + } + + fn encrypt_encoded(key: &RsaPrivateKey, encoded: &[u8]) -> Vec { + let value = + BoxedUint::from_be_slice(encoded, key.n_bits_precision()).expect("modulus-width block"); + let ciphertext = rsa::hazmat::rsa_encrypt(&key.to_public_key(), &value) + .expect("encoded representative below modulus") + .to_be_bytes(); + ciphertext[ciphertext.len() - key.size()..].to_vec() + } + + #[test] + fn fixed_width_recovery_checks_every_padding_boundary() { + // A fixed delimiter must enforce the complete RFC 8017 encoding, not + // merely return the expected number of bytes after implicit rejection. + let key = private_key(); + let provider = super::super::RustCryptoProvider; + for key_len in [16, 24, 32] { + let delimiter = key.size() - key_len - 1; + let mut encoded = vec![0x31; key.size()]; + encoded[0] = 0; + encoded[1] = 2; + encoded[delimiter] = 0; + let candidate = recover(&provider, &key, &encrypt_encoded(&key, &encoded), key_len) + .expect("valid recovery"); + assert!(candidate.valid()); + assert_eq!(candidate.bytes(), &encoded[delimiter + 1..]); + for (position, replacement) in + [(0, 1), (1, 1), (2, 0), (delimiter - 1, 0), (delimiter, 1)] + { + let previous = encoded[position]; + encoded[position] = replacement; + let candidate = recover(&provider, &key, &encrypt_encoded(&key, &encoded), key_len) + .expect("fixed-width invalid candidate"); + assert!(!candidate.valid(), "position {position}"); + assert_eq!(candidate.bytes().len(), key_len); + encoded[position] = previous; + } + } + } + + #[test] + fn out_of_range_representative_retains_invalidity() { + // A width-correct representative >= n must still produce a fixed-width + // candidate so the content operation executes before rejecting it. + let key = private_key(); + let bytes = key.n().to_be_bytes(); + let bytes = &bytes[bytes.len() - key.size()..]; + let candidate = recover(&super::super::RustCryptoProvider, &key, bytes, 16) + .expect("normalized invalid candidate"); + assert!(!candidate.valid()); + assert_eq!(candidate.bytes().len(), 16); + assert!(recover(&super::super::RustCryptoProvider, &key, &bytes[1..], 16).is_err()); + assert!(recover(&super::super::RustCryptoProvider, &key, bytes, 15).is_err()); + } +} diff --git a/src/xmldsig/digest.rs b/src/xmldsig/digest.rs index 178ec961..f67c43b9 100644 --- a/src/xmldsig/digest.rs +++ b/src/xmldsig/digest.rs @@ -1,9 +1,10 @@ //! Digest computation for XMLDSig `` processing. //! //! Implements [XMLDSig §6.1](https://www.w3.org/TR/xmldsig-core1/#sec-DigestMethod): -//! compute message digests over transform output bytes using SHA-family algorithms. +//! compute message digests over transform output bytes using the selected algorithm. //! -//! All digest computation uses RustCrypto hash implementations. +//! Operation digest computation uses the selected `CryptoProvider`; the built-in +//! standalone convenience path uses RustCrypto. use subtle::ConstantTimeEq; @@ -13,6 +14,12 @@ use subtle::ConstantTimeEq; /// SHA-256 is the recommended default for new signatures. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DigestAlgorithm { + /// MD5 compatibility capability; operation policy must explicitly permit it. + #[cfg(feature = "legacy-algorithms")] + Md5, + /// RIPEMD-160 compatibility capability; operation policy must explicitly permit it. + #[cfg(feature = "legacy-algorithms")] + Ripemd160, /// SHA-1 (160-bit). Disabled for signing by default. Sha1, /// SHA-224 (224-bit), required by XMLDSig 1.1 interoperability profiles. @@ -34,9 +41,31 @@ pub enum DigestAlgorithm { } impl DigestAlgorithm { + /// Every compiled digest capability; this registry grants no policy permission. + pub const ALL: [Self; 9 + if cfg!(feature = "legacy-algorithms") { + 2 + } else { + 0 + }] = [ + #[cfg(feature = "legacy-algorithms")] + Self::Md5, + #[cfg(feature = "legacy-algorithms")] + Self::Ripemd160, + Self::Sha1, + Self::Sha224, + Self::Sha256, + Self::Sha384, + Self::Sha512, + Self::Sha3_224, + Self::Sha3_256, + Self::Sha3_384, + Self::Sha3_512, + ]; /// Parse a digest algorithm from its XML namespace URI. /// /// Returns `None` for unrecognized URIs. + /// Recognition is capability, not permission: operation callers must check + /// their compiled digest policy before hashing, including X509Digest selectors. /// /// # URIs /// @@ -49,6 +78,12 @@ impl DigestAlgorithm { /// | SHA-512 | `http://www.w3.org/2001/04/xmlenc#sha512` | pub fn from_uri(uri: &str) -> Option { match uri { + // RFC 9231 §§2.1.1, 2.1.4 assign these exact digest identifiers. + // https://www.rfc-editor.org/rfc/rfc9231#section-2.1 + #[cfg(feature = "legacy-algorithms")] + "http://www.w3.org/2001/04/xmldsig-more#md5" => Some(Self::Md5), + #[cfg(feature = "legacy-algorithms")] + "http://www.w3.org/2001/04/xmlenc#ripemd160" => Some(Self::Ripemd160), "http://www.w3.org/2000/09/xmldsig#sha1" => Some(Self::Sha1), "http://www.w3.org/2001/04/xmldsig-more#sha224" => Some(Self::Sha224), "http://www.w3.org/2001/04/xmlenc#sha256" => Some(Self::Sha256), @@ -67,6 +102,10 @@ impl DigestAlgorithm { /// Return the XML namespace URI for this digest algorithm. pub fn uri(self) -> &'static str { match self { + #[cfg(feature = "legacy-algorithms")] + Self::Md5 => "http://www.w3.org/2001/04/xmldsig-more#md5", + #[cfg(feature = "legacy-algorithms")] + Self::Ripemd160 => "http://www.w3.org/2001/04/xmlenc#ripemd160", Self::Sha1 => "http://www.w3.org/2000/09/xmldsig#sha1", Self::Sha224 => "http://www.w3.org/2001/04/xmldsig-more#sha224", Self::Sha256 => "http://www.w3.org/2001/04/xmlenc#sha256", @@ -84,12 +123,25 @@ impl DigestAlgorithm { /// SHA-1 is deprecated and disabled by secure signing defaults. An explicit /// signing policy allowlist can enable it for a trusted compatibility boundary. pub fn signing_allowed(self) -> bool { - !matches!(self, Self::Sha1) + !matches!(self, Self::Sha1) && !self.requires_explicit_permission() + } + + /// Whether reference verification requires an explicit digest allowlist entry. + pub const fn requires_explicit_permission(self) -> bool { + match self { + #[cfg(feature = "legacy-algorithms")] + Self::Md5 | Self::Ripemd160 => true, + _ => false, + } } /// The expected output length in bytes. pub fn output_len(self) -> usize { match self { + #[cfg(feature = "legacy-algorithms")] + Self::Md5 => 16, + #[cfg(feature = "legacy-algorithms")] + Self::Ripemd160 => 20, Self::Sha1 => 20, Self::Sha224 | Self::Sha3_224 => 28, Self::Sha256 | Self::Sha3_256 => 32, diff --git a/src/xmldsig/keys.rs b/src/xmldsig/keys.rs index 4cd2800f..c049f895 100644 --- a/src/xmldsig/keys.rs +++ b/src/xmldsig/keys.rs @@ -99,6 +99,10 @@ impl HmacVerificationKey { }}; } Ok(match algorithm { + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::HmacMd5 => verify_hmac!(md5::Md5), + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::HmacRipemd160 => verify_hmac!(ripemd::Ripemd160), SignatureAlgorithm::HmacSha1 => verify_hmac!(sha1::Sha1), SignatureAlgorithm::HmacSha224 => verify_hmac!(sha2::Sha224), SignatureAlgorithm::HmacSha256 => verify_hmac!(sha2::Sha256), @@ -217,6 +221,18 @@ impl VerifyingKey for VerificationKey { } fn validate_policy(&self, policy: &crate::policy::VerificationPolicy) -> Result<(), DsigError> { let result = match self.algorithm { + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::RsaMd5 | SignatureAlgorithm::RsaRipemd160 => { + validate_rsa_signature_spki_with_minimum( + self.algorithm, + &self.public_key_bytes, + policy.key_trust.rsa_keys.minimum_modulus_bits, + ) + } + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::HmacMd5 + | SignatureAlgorithm::HmacRipemd160 + | SignatureAlgorithm::EcdsaRipemd160 => Ok(()), SignatureAlgorithm::Ed25519 | SignatureAlgorithm::Ed25519Ctx | SignatureAlgorithm::Ed25519Ph @@ -298,6 +314,26 @@ impl VerifyingKey for VerificationKey { return Err(KeyResolutionError::AlgorithmMismatch.into()); } let result = match algorithm { + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::RsaMd5 | SignatureAlgorithm::RsaRipemd160 => { + verify_rsa_signature_spki_primitive( + algorithm, + &self.public_key_bytes, + signed_data, + signature_value, + ) + } + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::EcdsaRipemd160 => verify_ecdsa_signature_spki( + algorithm, + &self.public_key_bytes, + signed_data, + signature_value, + ), + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::HmacMd5 | SignatureAlgorithm::HmacRipemd160 => { + return Err(KeyResolutionError::AlgorithmMismatch.into()); + } SignatureAlgorithm::Ed25519 | SignatureAlgorithm::Ed25519Ctx | SignatureAlgorithm::Ed25519Ph @@ -385,14 +421,7 @@ struct PolicyBoundVerificationKey { impl VerifyingKey for PolicyBoundVerificationKey { fn verification_spki(&self, algorithm: SignatureAlgorithm) -> Result, DsigError> { // Preserve strength checks carried by this resolver-produced handle. - if matches!( - algorithm, - SignatureAlgorithm::RsaSha1 - | SignatureAlgorithm::RsaSha224 - | SignatureAlgorithm::RsaSha256 - | SignatureAlgorithm::RsaSha384 - | SignatureAlgorithm::RsaSha512 - ) { + if algorithm.is_rsa() { validate_rsa_signature_spki_with_minimum( algorithm, &self.key.public_key_bytes, @@ -453,11 +482,7 @@ impl VerifyingKey for PolicyBoundVerificationKey { self.dsa_minimum_bits, ) } - SignatureAlgorithm::RsaSha1 - | SignatureAlgorithm::RsaSha224 - | SignatureAlgorithm::RsaSha256 - | SignatureAlgorithm::RsaSha384 - | SignatureAlgorithm::RsaSha512 => verify_rsa_signature_spki_with_minimum( + algorithm if algorithm.is_rsa() => verify_rsa_signature_spki_with_minimum( algorithm, &self.key.public_key_bytes, signed_data, @@ -1209,14 +1234,7 @@ impl DefaultKeyResolver { dsa_key_value_to_spki_der(p, q, g, y)? } KeyValueInfo::Rsa { modulus, exponent } => { - if !matches!( - algorithm, - SignatureAlgorithm::RsaSha1 - | SignatureAlgorithm::RsaSha224 - | SignatureAlgorithm::RsaSha256 - | SignatureAlgorithm::RsaSha384 - | SignatureAlgorithm::RsaSha512 - ) { + if !algorithm.is_rsa() { return Err(KeyResolutionError::AlgorithmMismatch); } rsa_key_value_to_spki_der(modulus, exponent)? @@ -1310,6 +1328,14 @@ impl DefaultKeyResolver { !document_sources || matches!(source, KeyInfoSource::KeyName(_)); let resolved = match source { KeyInfoSource::X509Data(info) => { + // Enforce selector permissions before candidate work or + // hashing, including direct callers of this resolver. + for (uri, _) in &info.digests { + let algorithm = super::DigestAlgorithm::from_uri(uri).ok_or_else(|| { + KeyResolutionError::UnsupportedDigestAlgorithm(uri.clone()) + })?; + policy.check_digest_algorithm(algorithm)?; + } if if info.certificate_chain.is_empty() { x509_data_has_lookup_identifiers(info) } else { @@ -1669,29 +1695,13 @@ fn validate_spki_algorithm( .map_err(|_| KeyResolutionError::InvalidPublicKey)?; Ok(()) } - ( - SignatureAlgorithm::RsaSha1 - | SignatureAlgorithm::RsaSha224 - | SignatureAlgorithm::RsaSha256 - | SignatureAlgorithm::RsaSha384 - | SignatureAlgorithm::RsaSha512, - PublicKey::RSA(_), - ) => Ok(()), - ( - SignatureAlgorithm::EcdsaSha1 - | SignatureAlgorithm::EcdsaSha224 - | SignatureAlgorithm::EcdsaSha256 - | SignatureAlgorithm::EcdsaSha384 - | SignatureAlgorithm::EcdsaSha512 - | SignatureAlgorithm::EcdsaSha3_224 - | SignatureAlgorithm::EcdsaSha3_256 - | SignatureAlgorithm::EcdsaSha3_384 - | SignatureAlgorithm::EcdsaSha3_512, - PublicKey::EC(ec), - ) if matches!( - curve_oid.as_deref(), - Some(EC_P256_OID | EC_P384_OID | EC_P521_OID) - ) => + (method, PublicKey::RSA(_)) if method.is_rsa() => Ok(()), + (method, PublicKey::EC(ec)) + if method.ecdsa_digest().is_some() + && 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)?; @@ -2050,7 +2060,7 @@ mod tests { { return Err(crate::provider::ProviderError::Unsupported { operation: crate::provider::ProviderOperation::VerifyCertificate, - algorithm: Some(algorithm.oid().to_owned()), + algorithm: algorithm.oid().map(str::to_owned), }); } crate::provider::default_provider().verify_x509_signature( @@ -4598,6 +4608,129 @@ mod tests { ); } + #[cfg(feature = "legacy-algorithms")] + #[test] + fn x509_digest_selectors_obey_operation_policy() { + // Both lookup-only and embedded selectors must reject a denied digest + // before certificate selection; explicit permission must still work. + for algorithm in [ + super::super::DigestAlgorithm::Md5, + super::super::DigestAlgorithm::Ripemd160, + ] { + let certificate = certificate_der(RSA_4096_CERTIFICATE); + let digest = super::super::compute_digest(algorithm, &certificate); + let resolver = DefaultKeyResolver::new(KeyResolverConfig { + lookup_certs: vec![certificate.clone()], + trusted_certs: vec![ + certificate.clone(), + certificate_der(include_str!("../../tests/fixtures/keys/ca2cert.pem")), + certificate_der(include_str!("../../tests/fixtures/keys/cacert.pem")), + ], + ..KeyResolverConfig::default() + }); + for embedded in [false, true] { + let cert_xml = if embedded { + format!( + "{}", + STANDARD.encode(&certificate) + ) + } else { + String::new() + }; + let xml = replace_unprefixed_key_info( + X509_DIGEST_SIGNATURE, + &format!( + "{cert_xml}{}", + algorithm.uri(), + STANDARD.encode(&digest) + ), + ); + let provider = RejectSecondSha512Provider { + sha512_calls: AtomicUsize::new(0), + verification_calls: AtomicUsize::new(0), + reject_verification_call: None, + rejected_verification_data: None, + }; + assert!( + matches!(super::super::VerifyContext::new().key_resolver(&resolver).provider(&provider).verify(&xml), + Err(DsigError::Policy(crate::policy::PolicyViolation::Algorithm { algorithm: uri, .. })) if uri == algorithm.uri()) + ); + assert_eq!(provider.sha512_calls.load(Ordering::SeqCst), 0); + assert_eq!(provider.verification_calls.load(Ordering::SeqCst), 0); + // Direct resolver callers must not bypass the same snapshot. + let info = KeyInfo { + sources: vec![KeyInfoSource::X509Data(X509DataInfo { + digests: vec![(algorithm.uri().into(), digest.clone())], + ..X509DataInfo::default() + })], + }; + assert!(matches!( + resolver.resolve_with_policy( + Some(&info), + SignatureAlgorithm::RsaSha512, + &crate::policy::VerificationPolicy::default() + ), + Err(DsigError::Policy(_)) + )); + let policy = crate::policy::VerificationPolicy { + digest_algorithms: Some( + [algorithm, super::super::DigestAlgorithm::Sha512].into(), + ), + ..crate::policy::VerificationPolicy::default() + }; + assert_eq!( + super::super::VerifyContext::new() + .key_resolver(&resolver) + .policy(policy.clone()) + .verify(&xml) + .expect("explicitly permitted selector must verify") + .status, + super::super::DsigStatus::Valid + ); + // Referenced KeyInfo and retrieved X509Data must not create a + // separate algorithm permission path during materialization. + let start = xml.find("").expect("fixture has KeyInfo"); + let end = + xml.find("").expect("fixture closes KeyInfo") + "".len(); + let target = &xml[start..end]; + let referenced = replace_unprefixed_key_info( + &xml, + &format!( + "{}", + target.replace("", "") + ), + ); + let retrieved = replace_unprefixed_key_info( + &xml, + &format!( + "{}", + target + .strip_prefix("") + .expect("selected range starts at KeyInfo") + .strip_suffix("") + .expect("selected range ends at KeyInfo") + .replace("", "") + ), + ); + for indirect in [referenced, retrieved] { + assert!( + matches!(super::super::VerifyContext::new().key_resolver(&resolver).verify(&indirect), + Err(DsigError::Policy(crate::policy::PolicyViolation::Algorithm { algorithm: uri, .. })) if uri == algorithm.uri()) + ); + assert_eq!( + super::super::VerifyContext::new() + .key_resolver(&resolver) + .policy(policy.clone()) + .verify(&indirect) + .expect("permitted indirect selector must verify") + .status, + super::super::DsigStatus::Valid + ); + } + } + } + } + #[test] fn generic_key_resolution_keeps_legacy_capability_source_independent() { let certificate = diff --git a/src/xmldsig/parse.rs b/src/xmldsig/parse.rs index ce837f5b..6ae0bf69 100644 --- a/src/xmldsig/parse.rs +++ b/src/xmldsig/parse.rs @@ -104,6 +104,21 @@ pub(crate) const MAX_X509_DATA_TOTAL_BINARY_LEN: usize = 1_048_576; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum SignatureAlgorithm { + /// RSA PKCS#1 v1.5 with MD5; explicit compatibility permission required. + #[cfg(feature = "legacy-algorithms")] + RsaMd5, + /// RSA PKCS#1 v1.5 with RIPEMD-160; explicit compatibility permission required. + #[cfg(feature = "legacy-algorithms")] + RsaRipemd160, + /// HMAC-MD5; explicit compatibility permission required. + #[cfg(feature = "legacy-algorithms")] + HmacMd5, + /// HMAC-RIPEMD-160; explicit compatibility permission required. + #[cfg(feature = "legacy-algorithms")] + HmacRipemd160, + /// ECDSA-RIPEMD-160; the public key selects the curve. + #[cfg(feature = "legacy-algorithms")] + EcdsaRipemd160, /// DSA with SHA-1. Legacy algorithm disabled for signing by default. DsaSha1, /// DSA with SHA-256 as defined by XMLDSig 1.1. @@ -162,7 +177,22 @@ pub enum SignatureAlgorithm { impl SignatureAlgorithm { /// Every signature algorithm recognized by this release. - pub const ALL: [Self; 35] = [ + pub const ALL: [Self; + 35 + if cfg!(feature = "legacy-algorithms") { + 5 + } else { + 0 + }] = [ + #[cfg(feature = "legacy-algorithms")] + Self::RsaMd5, + #[cfg(feature = "legacy-algorithms")] + Self::RsaRipemd160, + #[cfg(feature = "legacy-algorithms")] + Self::HmacMd5, + #[cfg(feature = "legacy-algorithms")] + Self::HmacRipemd160, + #[cfg(feature = "legacy-algorithms")] + Self::EcdsaRipemd160, Self::DsaSha1, Self::DsaSha256, Self::HmacSha1, @@ -201,8 +231,10 @@ impl SignatureAlgorithm { ]; /// Digest selected by an ECDSA method, independently of its key's curve. - pub(crate) const fn ecdsa_digest(self) -> Option { + pub const fn ecdsa_digest(self) -> Option { match self { + #[cfg(feature = "legacy-algorithms")] + Self::EcdsaRipemd160 => Some(DigestAlgorithm::Ripemd160), Self::EcdsaSha1 => Some(DigestAlgorithm::Sha1), Self::EcdsaSha224 => Some(DigestAlgorithm::Sha224), Self::EcdsaSha256 => Some(DigestAlgorithm::Sha256), @@ -256,6 +288,10 @@ impl SignatureAlgorithm { #[must_use] pub const fn hmac_output_bits(self) -> Option { match self { + #[cfg(feature = "legacy-algorithms")] + Self::HmacMd5 => Some(128), + #[cfg(feature = "legacy-algorithms")] + Self::HmacRipemd160 => Some(160), Self::HmacSha1 => Some(160), Self::HmacSha224 => Some(224), Self::HmacSha256 => Some(256), @@ -269,6 +305,18 @@ impl SignatureAlgorithm { #[must_use] pub fn from_uri(uri: &str) -> Option { match uri { + #[cfg(feature = "legacy-algorithms")] + "http://www.w3.org/2001/04/xmldsig-more#rsa-md5" => Some(Self::RsaMd5), + #[cfg(feature = "legacy-algorithms")] + "http://www.w3.org/2001/04/xmldsig-more#rsa-ripemd160" => Some(Self::RsaRipemd160), + #[cfg(feature = "legacy-algorithms")] + "http://www.w3.org/2001/04/xmldsig-more#hmac-md5" => Some(Self::HmacMd5), + #[cfg(feature = "legacy-algorithms")] + "http://www.w3.org/2001/04/xmldsig-more#hmac-ripemd160" => Some(Self::HmacRipemd160), + // RFC 9231 §2.3.6 assigns the 2007 namespace to ECDSA-RIPEMD160. + // https://www.rfc-editor.org/rfc/rfc9231.html#section-2.3.6 + #[cfg(feature = "legacy-algorithms")] + "http://www.w3.org/2007/05/xmldsig-more#ecdsa-ripemd160" => Some(Self::EcdsaRipemd160), "http://www.w3.org/2000/09/xmldsig#dsa-sha1" => Some(Self::DsaSha1), "http://www.w3.org/2009/xmldsig11#dsa-sha256" => Some(Self::DsaSha256), "http://www.w3.org/2000/09/xmldsig#hmac-sha1" => Some(Self::HmacSha1), @@ -310,6 +358,16 @@ impl SignatureAlgorithm { #[must_use] pub fn uri(self) -> &'static str { match self { + #[cfg(feature = "legacy-algorithms")] + Self::RsaMd5 => "http://www.w3.org/2001/04/xmldsig-more#rsa-md5", + #[cfg(feature = "legacy-algorithms")] + Self::RsaRipemd160 => "http://www.w3.org/2001/04/xmldsig-more#rsa-ripemd160", + #[cfg(feature = "legacy-algorithms")] + Self::HmacMd5 => "http://www.w3.org/2001/04/xmldsig-more#hmac-md5", + #[cfg(feature = "legacy-algorithms")] + Self::HmacRipemd160 => "http://www.w3.org/2001/04/xmldsig-more#hmac-ripemd160", + #[cfg(feature = "legacy-algorithms")] + Self::EcdsaRipemd160 => "http://www.w3.org/2007/05/xmldsig-more#ecdsa-ripemd160", Self::DsaSha1 => "http://www.w3.org/2000/09/xmldsig#dsa-sha1", Self::DsaSha256 => "http://www.w3.org/2009/xmldsig11#dsa-sha256", Self::HmacSha1 => "http://www.w3.org/2000/09/xmldsig#hmac-sha1", @@ -343,11 +401,41 @@ impl SignatureAlgorithm { /// Whether this algorithm is allowed for signing (not just verification). #[must_use] pub fn signing_allowed(self) -> bool { + if self.requires_explicit_permission() { + return false; + } !matches!( self, Self::RsaSha1 | Self::DsaSha1 | Self::HmacSha1 | Self::EcdsaSha1 | Self::PostQuantum(_) ) } + + /// Whether this optional historical method requires explicit policy permission. + pub const fn requires_explicit_permission(self) -> bool { + match self { + #[cfg(feature = "legacy-algorithms")] + Self::RsaMd5 + | Self::RsaRipemd160 + | Self::HmacMd5 + | Self::HmacRipemd160 + | Self::EcdsaRipemd160 => true, + _ => false, + } + } + + /// Whether this method uses an RSA key and PKCS#1 signature encoding. + pub const fn is_rsa(self) -> bool { + match self { + #[cfg(feature = "legacy-algorithms")] + Self::RsaMd5 | Self::RsaRipemd160 => true, + Self::RsaSha1 + | Self::RsaSha224 + | Self::RsaSha256 + | Self::RsaSha384 + | Self::RsaSha512 => true, + _ => false, + } + } } /// Parsed `` element. @@ -1647,6 +1735,40 @@ pub(crate) fn parse_x509_data_dispatch_with_budget_and_provider( Ok(info) } +/// Check untrusted selector requests before parsing hashes embedded certificates. +/// This borrows the operation's existing policy, without a parallel policy knob. +pub(crate) fn validate_x509_digest_policy( + node: Node, + check: &impl Fn(DigestAlgorithm) -> Result<(), crate::policy::PolicyViolation>, +) -> Result<(), ParseError> { + fn check_data( + data: Node, + check: &impl Fn(DigestAlgorithm) -> Result<(), crate::policy::PolicyViolation>, + ) -> Result<(), ParseError> { + for child in element_children(data) { + if child.has_tag_name((XMLDSIG11_NS, "X509Digest")) { + let uri = required_algorithm_attr(child, "X509Digest")?; + // Unknown URIs retain the parser/resolver's unsupported- + // algorithm error, not a policy denial; no primitive can run + // for them. Gate every recognized selector before hashing. + if let Some(algorithm) = DigestAlgorithm::from_uri(uri) { + check(algorithm)?; + } + } + } + Ok(()) + } + if node.has_tag_name((XMLDSIG_NS, "X509Data")) { + return check_data(node, check); + } + for child in element_children(node) { + if child.has_tag_name((XMLDSIG_NS, "X509Data")) { + check_data(child, check)?; + } + } + Ok(()) +} + fn build_x509_certificate_chain( info: &X509DataInfo, provider: &dyn crate::provider::CryptoProvider, diff --git a/src/xmldsig/sign.rs b/src/xmldsig/sign.rs index dc57ab1e..d593a301 100644 --- a/src/xmldsig/sign.rs +++ b/src/xmldsig/sign.rs @@ -403,31 +403,18 @@ fn expected_signature_output_len( .eddsa_signature_len() .ok_or(SigningKeyError::InvalidPublicKeyInfo)?, ( - SignatureAlgorithm::RsaSha1 - | SignatureAlgorithm::RsaSha224 - | SignatureAlgorithm::RsaSha256 - | SignatureAlgorithm::RsaSha384 - | SignatureAlgorithm::RsaSha512, + method, SigningPublicKeyInfo::Rsa { modulus, exponent, .. }, - ) => policy + ) if method.is_rsa() => policy .rsa_keys .validate_components("signing", &modulus, &exponent)?, - ( - SignatureAlgorithm::EcdsaSha1 - | SignatureAlgorithm::EcdsaSha224 - | SignatureAlgorithm::EcdsaSha256 - | SignatureAlgorithm::EcdsaSha384 - | SignatureAlgorithm::EcdsaSha512 - | SignatureAlgorithm::EcdsaSha3_224 - | SignatureAlgorithm::EcdsaSha3_256 - | SignatureAlgorithm::EcdsaSha3_384 - | SignatureAlgorithm::EcdsaSha3_512, - SigningPublicKeyInfo::Ec { public_key, .. }, - ) if public_key.first() == Some(&0x04) - && public_key.len() > 1 - && (public_key.len() - 1).is_multiple_of(2) => + (method, SigningPublicKeyInfo::Ec { public_key, .. }) + if method.ecdsa_digest().is_some() + && public_key.first() == Some(&0x04) + && public_key.len() > 1 + && (public_key.len() - 1).is_multiple_of(2) => { // XMLDSig serializes ECDSA as fixed-width r || s. An uncompressed // SEC1 public point is 0x04 || x || y with the same field width. @@ -453,14 +440,9 @@ fn expected_signature_output_len( } component_len.saturating_mul(2) } - ( - SignatureAlgorithm::HmacSha1 - | SignatureAlgorithm::HmacSha224 - | SignatureAlgorithm::HmacSha256 - | SignatureAlgorithm::HmacSha384 - | SignatureAlgorithm::HmacSha512, - SigningPublicKeyInfo::Hmac { key_bits }, - ) => { + (method, SigningPublicKeyInfo::Hmac { key_bits }) + if method.hmac_output_bits().is_some() => + { policy.hmac.validate_key_bits(key_bits)?; let output_bits = hmac_output_length_bits.unwrap_or( algorithm @@ -899,6 +881,18 @@ impl SigningKey for RsaSigningKey { canonical_signed_info: &[u8], ) -> Result, SigningKeyError> { match algorithm { + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::RsaMd5 => sign_rsa_pkcs1v15_with_rng( + provider, + RsaPkcs1v15SigningKey::::new(self.key.clone()), + canonical_signed_info, + ), + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::RsaRipemd160 => sign_rsa_pkcs1v15_with_rng( + provider, + RsaPkcs1v15SigningKey::::new(self.key.clone()), + canonical_signed_info, + ), SignatureAlgorithm::RsaSha1 => sign_rsa_pkcs1v15_with_rng( provider, RsaPkcs1v15SigningKey::::new(self.key.clone()), @@ -979,6 +973,10 @@ impl SigningKey for HmacSigningKey { }}; } Ok(match algorithm { + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::HmacMd5 => sign_hmac!(md5::Md5), + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::HmacRipemd160 => sign_hmac!(ripemd::Ripemd160), SignatureAlgorithm::HmacSha1 => sign_hmac!(sha1::Sha1), SignatureAlgorithm::HmacSha224 => sign_hmac!(sha2::Sha224), SignatureAlgorithm::HmacSha256 => sign_hmac!(sha2::Sha256), diff --git a/src/xmldsig/signature.rs b/src/xmldsig/signature.rs index 7782268d..074a3f36 100644 --- a/src/xmldsig/signature.rs +++ b/src/xmldsig/signature.rs @@ -47,6 +47,28 @@ pub(crate) fn signature_value_matches_algorithm_with_encoding( encoding: EcdsaSignatureValueEncoding, ) -> bool { match algorithm { + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::RsaMd5 | SignatureAlgorithm::RsaRipemd160 => { + (1..=crate::hard_limits::RSA_MODULUS_BIT_CEILING / 8).contains(&signature_value.len()) + } + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::HmacMd5 | SignatureAlgorithm::HmacRipemd160 => algorithm + .hmac_output_bits() + .is_some_and(|bits| (1..=bits / 8).contains(&signature_value.len())), + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::EcdsaRipemd160 => { + [32, 48, 66] + .into_iter() + .any(|component_len| match encoding { + EcdsaSignatureValueEncoding::XmlDsig => { + signature_value.len() == component_len * 2 + } + EcdsaSignatureValueEncoding::XmlSecAsn1Der => { + inspect_der_encoded_ecdsa_signature(signature_value, component_len) + .is_ok_and(|value| value.is_some()) + } + }) + } SignatureAlgorithm::PostQuantum(algorithm) => { signature_value.len() == algorithm.signature_len() } @@ -155,30 +177,12 @@ pub(crate) fn signature_value_matches_spki_with_encoding( Ok(algorithm.dsa_component_len() == Some(component_len) && signature_value.len() == component_len * 2) } - ( - SignatureAlgorithm::RsaSha1 - | SignatureAlgorithm::RsaSha224 - | SignatureAlgorithm::RsaSha256 - | SignatureAlgorithm::RsaSha384 - | SignatureAlgorithm::RsaSha512, - PublicKey::RSA(_), - ) => { + (method, PublicKey::RSA(_)) if method.is_rsa() => { let key = rsa::RsaPublicKey::from_public_key_der(public_key_spki_der) .map_err(|_| SignatureVerificationError::InvalidKeyDer)?; Ok(signature_value.len() == key.size()) } - ( - SignatureAlgorithm::EcdsaSha1 - | SignatureAlgorithm::EcdsaSha224 - | SignatureAlgorithm::EcdsaSha256 - | SignatureAlgorithm::EcdsaSha384 - | SignatureAlgorithm::EcdsaSha512 - | SignatureAlgorithm::EcdsaSha3_224 - | SignatureAlgorithm::EcdsaSha3_256 - | SignatureAlgorithm::EcdsaSha3_384 - | SignatureAlgorithm::EcdsaSha3_512, - PublicKey::EC(ec), - ) => { + (method, PublicKey::EC(ec)) if method.ecdsa_digest().is_some() => { validate_ec_public_key_encoding(&ec, &spki.subject_public_key.data)?; let (_, component_len) = ecdsa_curve_and_component_len(&spki, &ec)?; match encoding { @@ -397,6 +401,14 @@ pub(crate) fn verify_rsa_signature_spki_primitive( return Ok(false); }; let verified = match algorithm { + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::RsaMd5 => RsaVerifyingKey::::new(key) + .verify(signed_data, &signature) + .is_ok(), + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::RsaRipemd160 => RsaVerifyingKey::::new(key) + .verify(signed_data, &signature) + .is_ok(), SignatureAlgorithm::RsaSha1 => RsaVerifyingKey::::new(key) .verify(signed_data, &signature) .is_ok(), @@ -680,15 +692,12 @@ pub(crate) fn validate_rsa_key_components( fn ensure_rsa_signature_algorithm( algorithm: SignatureAlgorithm, ) -> Result<(), SignatureVerificationError> { - match algorithm { - SignatureAlgorithm::RsaSha1 - | SignatureAlgorithm::RsaSha224 - | SignatureAlgorithm::RsaSha256 - | SignatureAlgorithm::RsaSha384 - | SignatureAlgorithm::RsaSha512 => Ok(()), - _ => Err(SignatureVerificationError::UnsupportedAlgorithm { + if algorithm.is_rsa() { + Ok(()) + } else { + Err(SignatureVerificationError::UnsupportedAlgorithm { uri: algorithm.uri().to_string(), - }), + }) } } diff --git a/src/xmldsig/verify.rs b/src/xmldsig/verify.rs index cbcb2948..66a92593 100644 --- a/src/xmldsig/verify.rs +++ b/src/xmldsig/verify.rs @@ -2114,6 +2114,9 @@ fn verify_signature_node<'a>( signature_children .key_info_node .map(|node| { + super::parse::validate_x509_digest_policy(node, &|algorithm| { + ctx.policy.check_digest_algorithm(algorithm) + })?; parse_key_info_with_policy_budgets( node, ctx.provider, @@ -2169,18 +2172,7 @@ fn verify_signature_node<'a>( )?; } for reference in &signed_info.references { - if ctx - .policy - .digest_algorithms - .as_ref() - .is_some_and(|allowed| !allowed.contains(&reference.digest_method)) - { - return Err(crate::policy::PolicyViolation::Algorithm { - operation: "verification", - algorithm: reference.digest_method.uri().to_string(), - } - .into()); - } + ctx.policy.check_digest_algorithm(reference.digest_method)?; } enforce_reference_policies( &signed_info.references, @@ -2257,8 +2249,7 @@ fn verify_signature_node<'a>( outcome.merge(materialize_retrieval_methods_with_budgets( info, resolver, - ctx.policy.uris.retrieval_methods, - ctx.allowed_transform_uris(), + &ctx.policy, ctx.provider, &mut retrieval_budgets, &mut materialization.candidate_work, @@ -2629,6 +2620,10 @@ struct KeyInfoMaterializationState { } trait KeyInfoReferencePolicy { + fn check_digest_algorithm( + &self, + algorithm: DigestAlgorithm, + ) -> Result<(), crate::policy::PolicyViolation>; fn certificate_signature_algorithms( &self, ) -> Option<&crate::policy::CertificateSignatureAlgorithms>; @@ -2641,6 +2636,12 @@ trait KeyInfoReferencePolicy { } impl KeyInfoReferencePolicy for crate::policy::SigningPolicy { + fn check_digest_algorithm( + &self, + algorithm: DigestAlgorithm, + ) -> Result<(), crate::policy::PolicyViolation> { + self.check_digest_algorithm(algorithm) + } fn certificate_signature_algorithms( &self, ) -> Option<&crate::policy::CertificateSignatureAlgorithms> { @@ -2672,6 +2673,12 @@ impl KeyInfoReferencePolicy for crate::policy::SigningPolicy { } impl KeyInfoReferencePolicy for crate::policy::VerificationPolicy { + fn check_digest_algorithm( + &self, + algorithm: DigestAlgorithm, + ) -> Result<(), crate::policy::PolicyViolation> { + self.check_digest_algorithm(algorithm) + } fn certificate_signature_algorithms( &self, ) -> Option<&crate::policy::CertificateSignatureAlgorithms> { @@ -2783,6 +2790,10 @@ fn materialize_key_info_references_with_budgets( reason: "KeyInfoReference target must be KeyInfo", }); } + super::parse::validate_x509_digest_policy(node, &|algorithm| { + context.policy.check_digest_algorithm(algorithm) + }) + .map_err(map_key_info_parse_error)?; ( parse_key_info_with_policy_budgets( node, @@ -2856,6 +2867,10 @@ fn materialize_key_info_references_with_budgets( reason: "KeyInfoReference external target must be KeyInfo", }); } + super::parse::validate_x509_digest_policy(target, &|algorithm| { + context.policy.check_digest_algorithm(algorithm) + }) + .map_err(map_key_info_parse_error)?; let mut referenced = parse_key_info_with_policy_budgets_and_document_base( target, context.provider, @@ -2875,8 +2890,7 @@ fn materialize_key_info_references_with_budgets( nested_outcome.merge(materialize_retrieval_methods_with_budgets( &mut referenced, &external_resolver, - context.policy.retrieval_method_uris(), - context.policy.allowed_transforms(), + context.policy, context.provider, context.budgets, &mut materialization.candidate_work, @@ -3023,15 +3037,16 @@ pub fn materialize_verification_key_info_references( materialize_key_info_references_for_policy(key_info, resolver, policy, provider, xml_backend) } -fn materialize_retrieval_methods_with_budgets( +fn materialize_retrieval_methods_with_budgets( key_info: &mut KeyInfo, resolver: &UriReferenceResolver<'_>, - allowed_uri_types: UriTypeSet, - allowed_transforms: Option<&HashSet>, + policy: &P, provider: &dyn crate::provider::CryptoProvider, budgets: &mut RetrievalMaterializationBudgets<'_>, candidate_work: &mut usize, ) -> Result { + let allowed_uri_types = policy.retrieval_method_uris(); + let allowed_transforms = policy.allowed_transforms(); let retrieval_count = key_info .sources .iter() @@ -3189,6 +3204,10 @@ fn materialize_retrieval_methods_with_budgets( }); } }; + super::parse::validate_x509_digest_policy(node, &|algorithm| { + policy.check_digest_algorithm(algorithm) + }) + .map_err(map_key_info_parse_error)?; let data = parse_x509_data_dispatch_with_budget_and_provider( node, &mut total_binary_len, @@ -3272,11 +3291,13 @@ fn materialize_retrieval_methods( xml_backend: crate::XmlBackend::default(), }; let mut candidate_work = key_info.embedded_candidate_count(); + let mut policy = crate::policy::VerificationPolicy::default(); + policy.uris.retrieval_methods = allowed_uri_types; + policy.transforms.allowed_algorithms = allowed_transforms.cloned(); materialize_retrieval_methods_with_budgets( key_info, resolver, - allowed_uri_types, - allowed_transforms, + &policy, provider, &mut budgets, &mut candidate_work, @@ -3494,9 +3515,8 @@ fn process_authenticated_manifest_references( > ctx.policy.resources.max_transforms_per_reference || ctx .policy - .digest_algorithms - .as_ref() - .is_some_and(|allowed| !allowed.contains(&reference.digest_method)) + .check_digest_algorithm(reference.digest_method) + .is_err() { manifest_reference_invalid_result( reference, @@ -4156,6 +4176,24 @@ fn verify_with_algorithm( signature_value: &[u8], ) -> Result { match algorithm { + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::RsaMd5 | SignatureAlgorithm::RsaRipemd160 => Ok( + verify_rsa_signature_pem(algorithm, public_key_pem, signed_data, signature_value)?, + ), + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::EcdsaRipemd160 => Ok(verify_ecdsa_signature_pem( + algorithm, + public_key_pem, + signed_data, + signature_value, + )?), + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::HmacMd5 | SignatureAlgorithm::HmacRipemd160 => { + Err(SignatureVerificationError::UnsupportedAlgorithm { + uri: algorithm.uri().to_owned(), + } + .into()) + } SignatureAlgorithm::Ed25519 | SignatureAlgorithm::Ed25519Ctx | SignatureAlgorithm::Ed25519Ph @@ -7358,8 +7396,7 @@ mod tests { outcome.merge(materialize_retrieval_methods_with_budgets( &mut key_info, &resolver, - policy.uris.retrieval_methods, - policy.transforms.allowed_algorithms.as_ref(), + &policy, crate::provider::default_provider(), &mut budgets, &mut materialization.candidate_work, @@ -7846,11 +7883,12 @@ mod tests { }; let mut candidate_work = key_info.embedded_candidate_count(); + let mut policy = crate::policy::VerificationPolicy::default(); + policy.uris.retrieval_methods = UriTypeSet::ALL; let error = materialize_retrieval_methods_with_budgets( &mut key_info, &resolver, - UriTypeSet::ALL, - None, + &policy, crate::provider::default_provider(), &mut budgets, &mut candidate_work, diff --git a/src/xmldsig/x509.rs b/src/xmldsig/x509.rs index 603f0dac..45c35f43 100644 --- a/src/xmldsig/x509.rs +++ b/src/xmldsig/x509.rs @@ -2449,7 +2449,8 @@ mod tests { super::super::DigestAlgorithm::Sha3_512, ] { let expected = X509SignatureAlgorithm::Ecdsa(digest); - let oid = Oid::from_str(expected.oid()).expect("registered SHA3 ECDSA OID must parse"); + let oid = Oid::from_str(expected.oid().expect("registered signature OID")) + .expect("registered SHA3 ECDSA OID must parse"); assert_eq!( x509_signature_algorithm(&AlgorithmIdentifier::new(oid.clone(), None)), Ok(expected) diff --git a/src/xmlenc/decrypt.rs b/src/xmlenc/decrypt.rs index e6c011ed..dff1152d 100644 --- a/src/xmlenc/decrypt.rs +++ b/src/xmlenc/decrypt.rs @@ -85,6 +85,24 @@ impl KeyCandidateBudget { /// Supplies a content-encryption key for parsed XMLEnc data. pub trait DecryptionKeyResolver { + /// Resolve operation candidates without dropping implicit-rejection state. + /// Wrappers around recipient resolvers must forward this method; converting + /// recovered candidates to raw bytes loses the final content acceptance gate. + fn resolve_content_keys_with_policy( + &self, + provider: &dyn crate::provider::CryptoProvider, + algorithm: DataEncryptionAlgorithm, + encrypted_key: Option<&EncryptedKey>, + policy: &crate::policy::DecryptionPolicy, + budget: &mut KeyCandidateBudget, + ) -> Result, XmlEncError> { + self.resolve_key_candidates_with_policy(provider, algorithm, encrypted_key, policy, budget) + .map(|keys| { + keys.into_iter() + .map(crate::provider::RecoveredContentKey::confirmed) + .collect() + }) + } /// Resolve under the operation's immutable snapshot. RSA resolvers enforce /// `rsa_keys` before provider recovery; wrappers must forward this snapshot. /// @@ -320,18 +338,11 @@ impl<'a> DecryptContext<'a> { )?; let algorithm = DataEncryptionAlgorithm::from_uri(&encrypted.encryption_method.algorithm)?; - if operation - .policy() - .data_algorithms - .as_ref() - .is_some_and(|allowed| !allowed.contains(&algorithm)) - { - return Err(crate::policy::PolicyViolation::Algorithm { - operation: "decryption", - algorithm: encrypted.encryption_method.algorithm.clone(), - } - .into()); - } + crate::policy::check_content_algorithm( + operation.policy().data_algorithms.as_ref(), + algorithm, + "decryption", + )?; self.provider .require_capability(crate::provider::ProviderCapability::Decrypt(algorithm))?; validate_typed_cipher_values( @@ -372,13 +383,27 @@ impl<'a> DecryptContext<'a> { let mut last_error = None; for key in keys { let attempt = (|| { - validate_key_len(algorithm, &key)?; - let plaintext = self + validate_key_len(algorithm, key.bytes())?; + let result = self .provider - .decrypt_data(algorithm, &key, &ciphertext) - .map_err(|error| { - map_data_decryption_error(algorithm, ciphertext.len(), error) - })?; + .decrypt_data(algorithm, key.bytes(), &ciphertext); + // XMLEnc 1.1 §6.1.2: fallback recovery must still perform + // content work. Never release a CBC result merely because + // its unauthenticated padding happened to be valid. + // https://www.w3.org/TR/2013/REC-xmlenc-core1-20130411/#sec-bleichenbacher-attack + if !key.valid() { + if let Ok(mut plaintext) = result { + zeroize::Zeroize::zeroize(&mut plaintext); + } + return Err(if algorithm.cbc_block_len().is_some() { + XmlEncError::InvalidPadding + } else { + XmlEncError::AeadAuthenticationFailed + }); + } + let plaintext = result.map_err(|error| { + map_data_decryption_error(algorithm, ciphertext.len(), error) + })?; validate_provider_plaintext_len(algorithm, ciphertext.len(), plaintext.len())?; validate_plaintext_len( plaintext.len(), @@ -470,6 +495,7 @@ impl<'a> DecryptContext<'a> { #[derive(Clone)] pub struct SymmetricKeyDecryptor { key: Vec, + kind: crate::key_manager::SymmetricKeyKind, } impl fmt::Debug for SymmetricKeyDecryptor { @@ -482,19 +508,50 @@ impl fmt::Debug for SymmetricKeyDecryptor { } impl SymmetricKeyDecryptor { - /// Create a direct symmetric-key resolver. + /// Create a direct AES-key resolver. Key length never selects another family. pub fn new(key: impl Into>) -> Self { - Self { key: key.into() } + Self::with_kind(key, crate::key_manager::SymmetricKeyKind::Aes) + } + + /// Bind a pre-shared key to its trusted family before reading an algorithm + /// from the document. XMLEnc 1.1 §6.1.3 recommends key separation: + /// https://www.w3.org/TR/2013/REC-xmlenc-core1-20130411/#sec-backwards-compatibility-attacks + pub fn with_kind(key: impl Into>, kind: crate::key_manager::SymmetricKeyKind) -> Self { + Self { + key: key.into(), + kind, + } } } impl DecryptionKeyResolver for SymmetricKeyDecryptor { + fn resolve_content_keys_with_policy( + &self, + provider: &dyn crate::provider::CryptoProvider, + algorithm: DataEncryptionAlgorithm, + encrypted_key: Option<&EncryptedKey>, + policy: &crate::policy::DecryptionPolicy, + budget: &mut KeyCandidateBudget, + ) -> Result, XmlEncError> { + policy.validate()?; + if encrypted_key.is_some() { + return Err(XmlEncError::KeyNotFound); + } + budget.consume(1)?; + Ok(vec![crate::provider::RecoveredContentKey::confirmed( + self.resolve_key(provider, algorithm, None)?, + )]) + } + fn resolve_key( &self, _provider: &dyn crate::provider::CryptoProvider, algorithm: DataEncryptionAlgorithm, _encrypted_key: Option<&EncryptedKey>, ) -> Result, XmlEncError> { + if self.kind != algorithm.key_kind() { + return Err(XmlEncError::KeyNotFound); + } validate_key_len(algorithm, &self.key)?; Ok(self.key.clone()) } @@ -506,29 +563,71 @@ pub struct PrivateKeyDecryptor { key: Arc, } -/// Resolver backed by a pre-shared AES key-encryption key (KEK). +/// Resolver backed by a pre-shared, algorithm-family-bound key-encryption key. #[derive(Clone)] -pub struct KekDecryptor { - kek: Vec, +pub struct KekDecryptor<'a> { + kek: std::borrow::Cow<'a, [u8]>, + kind: crate::key_manager::SymmetricKeyKind, } -impl fmt::Debug for KekDecryptor { +impl fmt::Debug for KekDecryptor<'_> { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("KekDecryptor") .field("kek", &"[REDACTED]") + .field("kind", &self.kind) .finish() } } -impl KekDecryptor { +impl KekDecryptor<'static> { /// Create a resolver for RFC 3394 AES key-wrap `EncryptedKey` elements. pub fn new(kek: impl Into>) -> Self { - Self { kek: kek.into() } + Self::with_kind(kek, crate::key_manager::SymmetricKeyKind::Aes) + } + + /// Bind owned key material to its trusted family, independently of input XML. + pub fn with_kind(kek: impl Into>, kind: crate::key_manager::SymmetricKeyKind) -> Self { + Self { + kek: std::borrow::Cow::Owned(kek.into()), + kind, + } + } +} + +impl<'a> KekDecryptor<'a> { + /// Borrow a KEK for operation-scoped recovery without copying secret material. + pub fn borrowed(kek: &'a [u8]) -> Self { + Self::borrowed_with_kind(kek, crate::key_manager::SymmetricKeyKind::Aes) + } + + /// Borrow key material while retaining its caller-declared algorithm family. + pub fn borrowed_with_kind(kek: &'a [u8], kind: crate::key_manager::SymmetricKeyKind) -> Self { + Self { + kek: std::borrow::Cow::Borrowed(kek), + kind, + } } } -impl DecryptionKeyResolver for KekDecryptor { +impl DecryptionKeyResolver for KekDecryptor<'_> { + fn resolve_content_keys_with_policy( + &self, + provider: &dyn crate::provider::CryptoProvider, + algorithm: DataEncryptionAlgorithm, + encrypted_key: Option<&EncryptedKey>, + policy: &crate::policy::DecryptionPolicy, + budget: &mut KeyCandidateBudget, + ) -> Result, XmlEncError> { + policy.validate()?; + let encrypted_key = encrypted_key.ok_or(XmlEncError::KeyNotFound)?; + validate_encrypted_key_policy(encrypted_key, policy)?; + budget.consume(1)?; + Ok(vec![crate::provider::RecoveredContentKey::confirmed( + self.resolve_key(provider, algorithm, Some(encrypted_key))?, + )]) + } + fn resolve_key_candidates_with_policy( &self, provider: &dyn crate::provider::CryptoProvider, @@ -537,10 +636,10 @@ impl DecryptionKeyResolver for KekDecryptor { policy: &crate::policy::DecryptionPolicy, budget: &mut KeyCandidateBudget, ) -> Result>, XmlEncError> { - policy.validate()?; - // This resolver accepts only AES-KW and validates its fixed KEK width - // before unwrap; it cannot dispatch an RSA recovery without metadata. - self.resolve_key_candidates(provider, algorithm, encrypted_key, budget) + self.resolve_content_keys_with_policy(provider, algorithm, encrypted_key, policy, budget)? + .into_iter() + .map(|key| key.into_key().map_err(XmlEncError::from)) + .collect() } fn resolve_key( @@ -551,11 +650,17 @@ impl DecryptionKeyResolver for KekDecryptor { ) -> Result, XmlEncError> { let encrypted_key = encrypted_key.ok_or(XmlEncError::KeyNotFound)?; encrypted_key.encryption_method.validate_structure()?; + let wrap_algorithm = + KeyWrapAlgorithm::from_uri(&encrypted_key.encryption_method.algorithm)?; + // XMLEnc 1.1 section 6.1.3 recommends key separation between algorithms: + // https://www.w3.org/TR/2013/REC-xmlenc-core1-20130411/#sec-backwards-compatibility-attacks + // Equal AES-192 and TripleDES widths do not establish key identity. + if self.kind != wrap_algorithm.key_kind() { + return Err(XmlEncError::KeyNotFound); + } let wrapped = STANDARD .decode(&encrypted_key.cipher_data.value) .map_err(|error| XmlEncError::Base64(error.to_string()))?; - let wrap_algorithm = - KeyWrapAlgorithm::from_uri(&encrypted_key.encryption_method.algorithm)?; let expected_kek_len = wrap_algorithm.key_len(); if self.kek.len() != expected_kek_len { return Err(XmlEncError::InvalidKekSize { @@ -564,7 +669,7 @@ impl DecryptionKeyResolver for KekDecryptor { actual: self.kek.len(), }); } - let expected_wrapped_len = algorithm.key_len() + 8; + let expected_wrapped_len = algorithm.key_len() + wrap_algorithm.overhead(); if wrapped.len() != expected_wrapped_len { return Err(XmlEncError::InvalidWrappedKeyLength { expected: expected_wrapped_len, @@ -608,6 +713,19 @@ impl PrivateKeyDecryptor { } impl DecryptionKeyResolver for PrivateKeyDecryptor { + fn resolve_content_keys_with_policy( + &self, + provider: &dyn crate::provider::CryptoProvider, + algorithm: DataEncryptionAlgorithm, + encrypted_key: Option<&EncryptedKey>, + policy: &crate::policy::DecryptionPolicy, + budget: &mut KeyCandidateBudget, + ) -> Result, XmlEncError> { + policy.validate()?; + budget.consume(1)?; + self.resolve_key_with_policy(provider, algorithm, encrypted_key, policy) + .map(|key| vec![key]) + } fn resolve_key_candidates_with_policy( &self, provider: &dyn crate::provider::CryptoProvider, @@ -619,6 +737,7 @@ impl DecryptionKeyResolver for PrivateKeyDecryptor { policy.validate()?; budget.consume(1)?; self.resolve_key_with_policy(provider, algorithm, encrypted_key, policy) + .and_then(|key| key.into_key().map_err(XmlEncError::Provider)) .map(|key| vec![key]) } @@ -634,6 +753,7 @@ impl DecryptionKeyResolver for PrivateKeyDecryptor { encrypted_key, &crate::policy::DecryptionPolicy::default(), ) + .and_then(|key| key.into_key().map_err(XmlEncError::Provider)) } } @@ -646,9 +766,10 @@ impl PrivateKeyDecryptor { algorithm: DataEncryptionAlgorithm, encrypted_key: Option<&EncryptedKey>, policy: &crate::policy::DecryptionPolicy, - ) -> Result, XmlEncError> { + ) -> Result { policy.validate()?; let encrypted_key = encrypted_key.ok_or(XmlEncError::KeyNotFound)?; + validate_encrypted_key_policy(encrypted_key, policy)?; let width = policy.rsa_keys.validate_public_metadata( "decryption", self.key.rsa_modulus_bits(), @@ -674,22 +795,44 @@ impl PrivateKeyDecryptor { let transport = KeyTransportAlgorithm::from_uri(&encrypted_key.encryption_method.algorithm)?; let key = match transport { - KeyTransportAlgorithm::RsaOaepMgf1p => self.decrypt_oaep_mgf1p( - provider, - encrypted_key.encryption_method.oaep_digest.as_deref(), - encrypted_key.encryption_method.mgf_algorithm.as_deref(), - label, - &wrapped, - ), - KeyTransportAlgorithm::RsaOaep11 => self.decrypt_oaep11( - provider, - encrypted_key.encryption_method.oaep_digest.as_deref(), - encrypted_key.encryption_method.mgf_algorithm.as_deref(), - label, - &wrapped, - ), + #[cfg(feature = "legacy-algorithms")] + KeyTransportAlgorithm::RsaPkcs1v15 => { + if wrapped.len() != self.key.ciphertext_len() { + return Err(XmlEncError::InvalidWrappedKeyLength { + expected: self.key.ciphertext_len(), + actual: wrapped.len(), + }); + } + provider + .require_capability(crate::provider::ProviderCapability::Pkcs1v15Recovery)?; + // Recovery conceals padding/range rejection with a random CEK; + // only operational provider failures propagate here. The RFC + // padding-error rule is RFC 8017 §7.2.2's note: + // https://www.rfc-editor.org/rfc/rfc8017#section-7.2.2. + provider + .recover_pkcs1v15(self.key.as_ref(), &wrapped, algorithm.key_len()) + .map_err(XmlEncError::Provider) + } + KeyTransportAlgorithm::RsaOaepMgf1p => self + .decrypt_oaep_mgf1p( + provider, + encrypted_key.encryption_method.oaep_digest.as_deref(), + encrypted_key.encryption_method.mgf_algorithm.as_deref(), + label, + &wrapped, + ) + .map(crate::provider::RecoveredContentKey::confirmed), + KeyTransportAlgorithm::RsaOaep11 => self + .decrypt_oaep11( + provider, + encrypted_key.encryption_method.oaep_digest.as_deref(), + encrypted_key.encryption_method.mgf_algorithm.as_deref(), + label, + &wrapped, + ) + .map(crate::provider::RecoveredContentKey::confirmed), }?; - validate_key_len(algorithm, &key)?; + validate_key_len(algorithm, key.bytes())?; Ok(key) } } @@ -966,7 +1109,7 @@ fn resolve_content_key_candidates( resolver: &dyn DecryptionKeyResolver, policy: &crate::policy::DecryptionPolicy, budget: &mut KeyCandidateBudget, -) -> Result>, XmlEncError> { +) -> Result, XmlEncError> { let mut last_error = None; let mut candidates = match resolve_candidates_with_budget(resolver, provider, algorithm, None, policy, budget) { @@ -1024,9 +1167,9 @@ fn resolve_candidates_with_budget( encrypted_key: Option<&EncryptedKey>, policy: &crate::policy::DecryptionPolicy, budget: &mut KeyCandidateBudget, -) -> Result>, XmlEncError> { +) -> Result, XmlEncError> { let remaining_before = budget.remaining(); - let keys = resolver.resolve_key_candidates_with_policy( + let keys = resolver.resolve_content_keys_with_policy( provider, algorithm, encrypted_key, @@ -1067,23 +1210,33 @@ fn encrypted_key_applies_to_data( fn compatible_decryption_key_candidates( algorithm: DataEncryptionAlgorithm, - keys: Vec>, -) -> Result>, XmlEncError> { - let mut compatible = Vec::with_capacity(keys.len()); + mut keys: Vec, +) -> Result, XmlEncError> { + let mut accepted = 0; let mut last_error = None; - for key in keys { - match validate_key_len(algorithm, &key) { - Ok(()) if !compatible.iter().any(|existing| existing == &key) => { - compatible.push(key); + for index in 0..keys.len() { + let key = &keys[index]; + match validate_key_len(algorithm, key.bytes()) { + Ok(()) + if !keys[..accepted].iter().any(|existing| { + existing.bytes() == key.bytes() && existing.valid() == key.valid() + }) => + { + // Accepted entries form a stable prefix. Swapping an earlier + // rejected slot never moves an unvisited entry, and avoids a + // second allocation without changing candidate precedence. + keys.swap(accepted, index); + accepted += 1; } Ok(()) => {} Err(error) => last_error = Some(error), } } - if compatible.is_empty() { + keys.truncate(accepted); + if keys.is_empty() { return Err(last_error.unwrap_or(XmlEncError::KeyNotFound)); } - Ok(compatible) + Ok(keys) } fn validate_decryption_key_candidates( @@ -1099,12 +1252,7 @@ fn validate_decryption_key_candidates( } .into()); } - if actual > 1 - && matches!( - algorithm, - DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc - ) - { + if actual > 1 && algorithm.cbc_block_len().is_some() { return Err(XmlEncError::AmbiguousKeyCandidates { algorithm, actual }); } Ok(()) @@ -1120,7 +1268,9 @@ fn validate_encrypted_key_policy( if policy .key_transport_algorithms .as_ref() - .is_some_and(|allowed| !allowed.contains(&transport)) + .map_or(transport.requires_explicit_permission(), |allowed| { + !allowed.contains(&transport) + }) { return Err(crate::policy::PolicyViolation::Algorithm { operation: "decryption", @@ -1128,6 +1278,10 @@ fn validate_encrypted_key_policy( } .into()); } + #[cfg(feature = "legacy-algorithms")] + if transport == KeyTransportAlgorithm::RsaPkcs1v15 { + return Ok(()); + } let method = &encrypted_key.encryption_method; let digest = parse_oaep_digest(method.oaep_digest.as_deref())?; let mgf_digest = parse_oaep_mgf_digest(method.mgf_algorithm.as_deref())?; @@ -1162,7 +1316,9 @@ fn validate_encrypted_key_policy( if policy .key_wrap_algorithms .as_ref() - .is_some_and(|allowed| !allowed.contains(&wrap)) + .map_or(wrap.requires_explicit_permission(), |allowed| { + !allowed.contains(&wrap) + }) { return Err(crate::policy::PolicyViolation::Algorithm { operation: "decryption", @@ -1209,17 +1365,11 @@ fn validate_typed_cipher_values( maximum_plaintext: usize, maximum_cipher_values: usize, ) -> Result<(), XmlEncError> { - let maximum_ciphertext = match algorithm { - DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc => { - (maximum_plaintext / 16) - .saturating_add(1) - .saturating_mul(16) - .saturating_add(16) - } - DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm => { - maximum_plaintext.saturating_add(28) - } - }; + let maximum_ciphertext = algorithm + .ciphertext_len_for_plaintext(maximum_plaintext) + .ok_or(XmlEncError::InvalidEncryptionConfig( + "ciphertext size overflow".into(), + ))?; let projected = validate_cipher_value_len(&encrypted.cipher_data.value, maximum_ciphertext)?; if projected > maximum_ciphertext { return Err(crate::policy::PolicyViolation::ResourceLimit { @@ -1304,7 +1454,7 @@ fn validate_possible_plaintext_len( ciphertext_len: usize, maximum: usize, ) -> Result<(), XmlEncError> { - // CBC's minimum includes a 16-byte IV and one padded block; using that + // CBC's minimum includes an IV and one padded block; using that // maximum-padding case yields the safe pre-decryption plaintext lower bound. let framing = algorithm.minimum_ciphertext_len(); validate_plaintext_len(ciphertext_len.saturating_sub(framing), maximum) @@ -1317,8 +1467,8 @@ fn validate_provider_plaintext_len( ) -> Result<(), XmlEncError> { use crate::provider::{ProviderError, ProviderOperation}; - match algorithm { - DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm => { + match algorithm.cbc_block_len() { + None => { let expected = ciphertext_len - algorithm.minimum_ciphertext_len(); if plaintext_len != expected { return Err(ProviderError::InvalidOutputSize { @@ -1329,9 +1479,9 @@ fn validate_provider_plaintext_len( .into()); } } - DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc => { - let padded_len = ciphertext_len - 16; - let minimum = padded_len - 16; + Some(block) => { + let padded_len = ciphertext_len - block; + let minimum = padded_len - block; let maximum = padded_len - 1; if !(minimum..=maximum).contains(&plaintext_len) { return Err(ProviderError::InvalidOutputSizeRange { @@ -1367,41 +1517,53 @@ fn map_data_decryption_error( ) -> XmlEncError { use crate::provider::ProviderError; - match (algorithm, error) { - ( - DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm, - ProviderError::AuthenticationFailed, - ) => XmlEncError::AeadAuthenticationFailed, - ( - DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm, - ProviderError::InvalidInput(crate::provider::ProviderInputError::AesGcmFraming), - ) => XmlEncError::DataTooShort { - algorithm: "AES-GCM", - minimum: 28, - actual: ciphertext_len, - }, - ( - DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc, - ProviderError::InvalidInput(crate::provider::ProviderInputError::AesCbcFraming), - ) if ciphertext_len < 32 => XmlEncError::DataTooShort { - algorithm: "AES-CBC", - minimum: 32, - actual: ciphertext_len, - }, - ( - DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc, - ProviderError::InvalidInput(crate::provider::ProviderInputError::AesCbcFraming), - ) => XmlEncError::InvalidCbcCiphertextLength(ciphertext_len.saturating_sub(16)), - ( - DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc, - ProviderError::InvalidInput(crate::provider::ProviderInputError::AesCbcCiphertext), - ) => XmlEncError::InvalidPadding, - (_, error) => XmlEncError::Provider(error), + match error { + ProviderError::AuthenticationFailed if algorithm.cbc_block_len().is_none() => { + XmlEncError::AeadAuthenticationFailed + } + ProviderError::InvalidInput(crate::provider::ProviderInputError::AesGcmFraming) + if algorithm.cbc_block_len().is_none() => + { + XmlEncError::DataTooShort { + algorithm: "AES-GCM", + minimum: 28, + actual: ciphertext_len, + } + } + error @ ProviderError::InvalidInput(crate::provider::ProviderInputError::AesCbcFraming) + if algorithm.cbc_block_len().is_some() => + { + match super::types::validate_ciphertext_framing(algorithm, ciphertext_len) { + Err(error) => error, + Ok(()) => XmlEncError::Provider(error), + } + } + ProviderError::InvalidInput(crate::provider::ProviderInputError::AesCbcCiphertext) + if algorithm.cbc_block_len().is_some() => + { + XmlEncError::InvalidPadding + } + error => XmlEncError::Provider(error), } } #[cfg(test)] mod tests { + #[cfg(feature = "legacy-algorithms")] + #[test] + fn legacy_cbc_rejects_ambiguous_candidates_before_decryption() { + // CBC has no authentication tag: selecting a candidate by padding + // success is unsafe for DES and AES-192 just as for existing AES modes. + for algorithm in [ + super::DataEncryptionAlgorithm::TripleDesCbc, + super::DataEncryptionAlgorithm::Aes192Cbc, + ] { + assert!(matches!( + super::validate_decryption_key_candidates(algorithm, 2), + Err(super::XmlEncError::AmbiguousKeyCandidates { actual: 2, .. }) + )); + } + } use std::cell::{Cell, RefCell}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -1871,6 +2033,15 @@ mod tests { } impl crate::provider::CryptoProvider for PermissiveUnwrapProvider { + #[cfg(feature = "legacy-algorithms")] + fn recover_pkcs1v15( + &self, + key: &dyn crate::provider::KeyRecoveryKey, + ciphertext: &[u8], + key_len: usize, + ) -> Result { + key.recover_pkcs1v15(self, ciphertext, key_len) + } fn name(&self) -> &'static str { "permissive-unwrap-test" } @@ -3042,6 +3213,87 @@ mod tests { assert_eq!(provider.recover_calls.load(Ordering::Relaxed), 0); } + #[cfg(feature = "legacy-algorithms")] + #[test] + fn invalid_rsa_recovery_cannot_release_successful_cbc_plaintext() { + // Force the CBC provider to accept its padding, making this regression + // deterministic instead of waiting for random fallback plaintext. + let key = RsaPrivateKey::from_pkcs8_pem(include_str!( + "../../tests/fixtures/keys/rsa/rsa-2048-key.pem" + )) + .expect("valid RSA fixture"); + let resolver = PrivateKeyDecryptor::new(key); + for algorithm in [ + DataEncryptionAlgorithm::Aes128Cbc, + DataEncryptionAlgorithm::Aes192Cbc, + DataEncryptionAlgorithm::Aes256Cbc, + DataEncryptionAlgorithm::TripleDesCbc, + DataEncryptionAlgorithm::Aes128Gcm, + ] { + let provider = PermissiveUnwrapProvider { + plaintext: b"accepted CBC plaintext".to_vec(), + ..PermissiveUnwrapProvider::default() + }; + let mut encrypted = encrypted_data_with_recipients( + &[0; 16], + vec![associated_encrypted_key("recipient", None, None)], + None, + ); + encrypted.encryption_method.algorithm = algorithm.uri().into(); + encrypted.cipher_data.value = STANDARD.encode(vec![ + 0; + algorithm + .ciphertext_len_for_plaintext(provider.plaintext.len()) + .expect("bounded test frame") + ]); + encrypted.encrypted_keys[0].encryption_method.algorithm = + KeyTransportAlgorithm::RsaPkcs1v15.uri().into(); + encrypted.encrypted_keys[0].cipher_data.value = STANDARD.encode([0; 256]); + let result = DecryptContext::new(&resolver) + .provider(&provider) + .policy(crate::policy::DecryptionPolicy { + data_algorithms: Some([algorithm].into()), + key_transport_algorithms: Some([KeyTransportAlgorithm::RsaPkcs1v15].into()), + ..crate::policy::DecryptionPolicy::default() + }) + .decrypt_data(&encrypted); + assert!( + result.is_err(), + "invalid RSA padding must never release fallback plaintext" + ); + assert_eq!( + provider.decrypt_calls.load(Ordering::Relaxed), + 1, + "content decryption must run before rejecting the recovery" + ); + } + } + + #[test] + fn candidate_filter_reuses_storage_and_preserves_precedence() { + // Compact invalid widths and non-adjacent duplicates in place without + // reordering distinct candidates or allocating a second candidate list. + let keys = [ + vec![0; 8], + vec![2; 16], + vec![3; 16], + vec![2; 16], + vec![4; 32], + vec![5; 16], + ] + .into_iter() + .map(crate::provider::RecoveredContentKey::confirmed) + .collect::>(); + let allocation = keys.as_ptr(); + let keys = compatible_decryption_key_candidates(DataEncryptionAlgorithm::Aes128Gcm, keys) + .expect("compatible candidates survive"); + assert_eq!(keys.as_ptr(), allocation); + assert_eq!(keys.len(), 3); + for (key, expected) in keys.iter().zip([2, 3, 5]) { + assert_eq!(key.bytes(), &[expected; 16]); + } + } + #[test] fn custom_provider_recovers_with_an_opaque_private_key() { // The resolver knows only the public ciphertext width; private key diff --git a/src/xmlenc/encrypt.rs b/src/xmlenc/encrypt.rs index 1f3304af..66f7b3c4 100644 --- a/src/xmlenc/encrypt.rs +++ b/src/xmlenc/encrypt.rs @@ -526,7 +526,19 @@ impl EncryptedDataBuilder { validate_content_key(self.algorithm, key)?; key.clone() } else { - random_bytes(self.provider.as_ref(), self.algorithm.key_len())? + let key = random_bytes(self.provider.as_ref(), self.algorithm.key_len())?; + #[cfg(feature = "legacy-algorithms")] + let mut key = key; + #[cfg(feature = "legacy-algorithms")] + if self.algorithm == DataEncryptionAlgorithm::TripleDesCbc { + // RFC 3217 §3.1: generated DES CEKs use odd octet parity. + // Normalize here, not in wrapping, which also accepts AES keys. + // https://www.rfc-editor.org/rfc/rfc3217#section-3.1 + for octet in &mut key { + *octet = (*octet & 0xfe) | (((*octet & 0xfe).count_ones() as u8 & 1) ^ 1); + } + } + key }; let encrypted_keys = self .recipients @@ -589,17 +601,11 @@ impl EncryptedDataBuilder { if let EncryptedDataType::Other(uri) = &self.encrypted_type { validate_metadata("EncryptedData Type", Some(uri), metadata_limit)?; } - if policy - .data_algorithms - .as_ref() - .is_some_and(|allowed| !allowed.contains(&self.algorithm)) - { - return Err(crate::policy::PolicyViolation::Algorithm { - operation: "encryption", - algorithm: self.algorithm.to_string(), - } - .into()); - } + crate::policy::check_content_algorithm( + policy.data_algorithms.as_ref(), + self.algorithm, + "encryption", + )?; if self.recipients.len() > policy.resources.max_encryption_recipients { return Err(crate::policy::PolicyViolation::ResourceLimit { resource: crate::policy::resource_name::ENCRYPTION_RECIPIENTS, @@ -623,12 +629,46 @@ impl EncryptedDataBuilder { )?; for recipient in &self.recipients { match recipient { + #[cfg(feature = "legacy-algorithms")] + EncryptionRecipient::RsaPkcs1v15 { + public_key, + recipient, + key_name, + } => { + let algorithm = super::KeyTransportAlgorithm::RsaPkcs1v15; + if !policy + .key_transport_algorithms + .as_ref() + .is_some_and(|allowed| allowed.contains(&algorithm)) + { + return Err(crate::policy::PolicyViolation::Algorithm { + operation: "encryption", + algorithm: algorithm.uri().into(), + } + .into()); + } + validate_key_transport_recipient(public_key.as_ref(), policy)?; + validate_metadata( + "EncryptedKey Recipient", + recipient.as_deref(), + metadata_limit, + )?; + validate_key_name("EncryptedKey KeyName", key_name.as_deref(), metadata_limit)?; + } EncryptionRecipient::RsaOaep { public_key, parameters, recipient, key_name, } => { + // XMLEnc 1.1 §5.5.1 has no OAEP parameters. Its algorithm + // must use the separate typed recipient, never this OAEP path. + // https://www.w3.org/TR/xmlenc-core1/#sec-RSA-v1.5 + if parameters.algorithm.requires_explicit_permission() { + return Err(XmlEncError::InvalidEncryptionConfig( + "RSA-OAEP recipient requires an OAEP algorithm".into(), + )); + } validate_key_transport_recipient(public_key.as_ref(), policy)?; if policy .key_transport_algorithms @@ -674,7 +714,9 @@ impl EncryptedDataBuilder { if policy .key_wrap_algorithms .as_ref() - .is_some_and(|allowed| !allowed.contains(algorithm)) + .map_or(algorithm.requires_explicit_permission(), |allowed| { + !allowed.contains(algorithm) + }) { return Err(crate::policy::PolicyViolation::Algorithm { operation: "encryption", @@ -942,6 +984,29 @@ fn wrap_content_key( content_key: &[u8], ) -> Result { match recipient { + #[cfg(feature = "legacy-algorithms")] + EncryptionRecipient::RsaPkcs1v15 { + public_key, + recipient, + key_name, + } => { + provider.require_capability(crate::provider::ProviderCapability::Pkcs1v15Transport)?; + let ciphertext = provider.transport_pkcs1v15(public_key.as_ref(), content_key)?; + let expected = public_key.rsa_modulus().len(); + if ciphertext.len() != expected { + return Err(XmlEncError::InvalidWrappedKeyLength { + expected, + actual: ciphertext.len(), + }); + } + Ok(WrappedKey { + algorithm_uri: super::KeyTransportAlgorithm::RsaPkcs1v15.uri(), + oaep: None, + recipient: recipient.clone(), + key_name: key_name.clone(), + ciphertext, + }) + } EncryptionRecipient::RsaOaep { public_key, parameters, @@ -963,7 +1028,7 @@ fn wrap_content_key( provider .require_capability(crate::provider::ProviderCapability::KeyWrap(*algorithm))?; let wrapped = provider.wrap_key(*algorithm, kek, content_key)?; - let expected = content_key.len() + 8; + let expected = content_key.len() + algorithm.overhead(); if wrapped.len() != expected { return Err(XmlEncError::InvalidWrappedKeyLength { expected, @@ -2696,7 +2761,7 @@ mod tests { .expect_err("malformed provider output must fail before XML serialization"); assert!(matches!( error, - XmlEncError::DataTooShort { .. } | XmlEncError::InvalidCbcCiphertextLength(_) + XmlEncError::DataTooShort { .. } | XmlEncError::InvalidCbcCiphertextLength { .. } )); } } diff --git a/src/xmlenc/mod.rs b/src/xmlenc/mod.rs index 61288a63..6d6b7d56 100644 --- a/src/xmlenc/mod.rs +++ b/src/xmlenc/mod.rs @@ -8,8 +8,9 @@ //! RSA-OAEP or AES-KW recipients. The reciprocal decrypt APIs accept the same //! inline `CipherValue` profile. //! -//! External `CipherReference` resources, RSA PKCS#1 v1.5 key transport, and -//! unauthenticated legacy ciphers are intentionally outside this profile. +//! External `CipherReference` resources are outside this profile. Optional +//! `legacy-algorithms` adds independently policy-gated RSA-1.5 and TripleDES +//! mechanisms; it does not grant permission or authenticate CBC content. #![doc = include_str!("../../docs/xmlenc.md")] use crate::xml::dom::Node; diff --git a/src/xmlenc/types.rs b/src/xmlenc/types.rs index 9d2c54af..1d1b8bbf 100644 --- a/src/xmlenc/types.rs +++ b/src/xmlenc/types.rs @@ -28,6 +28,15 @@ pub enum EncryptedDataType { /// Supported content-encryption algorithms. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DataEncryptionAlgorithm { + /// Legacy three-key Triple DES CBC, available only with explicit policy permission. + #[cfg(feature = "legacy-algorithms")] + TripleDesCbc, + /// AES-192 CBC compatibility capability. + #[cfg(feature = "legacy-algorithms")] + Aes192Cbc, + /// AES-192 GCM compatibility capability. + #[cfg(feature = "legacy-algorithms")] + Aes192Gcm, /// AES-128 in CBC mode with XMLEnc padding. Aes128Cbc, /// AES-256 in CBC mode with XMLEnc padding. @@ -39,9 +48,24 @@ pub enum DataEncryptionAlgorithm { } impl DataEncryptionAlgorithm { + /// Key family required independently of the byte length (AES-192 and + /// three-key Triple DES both use 24 bytes). + pub const fn key_kind(self) -> crate::key_manager::SymmetricKeyKind { + #[cfg(feature = "legacy-algorithms")] + if matches!(self, Self::TripleDesCbc) { + return crate::key_manager::SymmetricKeyKind::Des; + } + crate::key_manager::SymmetricKeyKind::Aes + } /// Parse a supported XMLEnc content-encryption URI. pub fn from_uri(uri: &str) -> Result { match uri { + #[cfg(feature = "legacy-algorithms")] + "http://www.w3.org/2001/04/xmlenc#tripledes-cbc" => Ok(Self::TripleDesCbc), + #[cfg(feature = "legacy-algorithms")] + "http://www.w3.org/2001/04/xmlenc#aes192-cbc" => Ok(Self::Aes192Cbc), + #[cfg(feature = "legacy-algorithms")] + "http://www.w3.org/2009/xmlenc11#aes192-gcm" => Ok(Self::Aes192Gcm), "http://www.w3.org/2001/04/xmlenc#aes128-cbc" => Ok(Self::Aes128Cbc), "http://www.w3.org/2001/04/xmlenc#aes256-cbc" => Ok(Self::Aes256Cbc), "http://www.w3.org/2009/xmlenc11#aes128-gcm" => Ok(Self::Aes128Gcm), @@ -53,6 +77,8 @@ impl DataEncryptionAlgorithm { /// Required symmetric key length in bytes. pub const fn key_len(self) -> usize { match self { + #[cfg(feature = "legacy-algorithms")] + Self::TripleDesCbc | Self::Aes192Cbc | Self::Aes192Gcm => 24, Self::Aes128Cbc | Self::Aes128Gcm => 16, Self::Aes256Cbc | Self::Aes256Gcm => 32, } @@ -61,6 +87,12 @@ impl DataEncryptionAlgorithm { /// Return the standard XMLEnc algorithm URI. pub const fn uri(self) -> &'static str { match self { + #[cfg(feature = "legacy-algorithms")] + Self::TripleDesCbc => "http://www.w3.org/2001/04/xmlenc#tripledes-cbc", + #[cfg(feature = "legacy-algorithms")] + Self::Aes192Cbc => "http://www.w3.org/2001/04/xmlenc#aes192-cbc", + #[cfg(feature = "legacy-algorithms")] + Self::Aes192Gcm => "http://www.w3.org/2009/xmlenc11#aes192-gcm", Self::Aes128Cbc => "http://www.w3.org/2001/04/xmlenc#aes128-cbc", Self::Aes256Cbc => "http://www.w3.org/2001/04/xmlenc#aes256-cbc", Self::Aes128Gcm => "http://www.w3.org/2009/xmlenc11#aes128-gcm", @@ -70,20 +102,44 @@ impl DataEncryptionAlgorithm { /// Minimum standard wire length for ciphertext produced by this algorithm. pub(crate) const fn minimum_ciphertext_len(self) -> usize { + match self.cbc_block_len() { + Some(block) => block * 2, + None => 28, + } + } + + /// Compile-time capability is not the operation's permission. + /// Whether this capability must be explicitly selected in the operation allowlist. + pub const fn requires_explicit_permission(self) -> bool { + match self { + #[cfg(feature = "legacy-algorithms")] + Self::TripleDesCbc | Self::Aes192Cbc | Self::Aes192Gcm => true, + _ => false, + } + } + + pub(crate) const fn cbc_block_len(self) -> Option { + // XMLEnc 1.1 §§5.2.2-5.2.4: CBC frames prefix one cipher block; + // GCM instead prefixes a 96-bit nonce and appends a 128-bit tag. + // https://www.w3.org/TR/xmlenc-core1/#sec-Block-Encryption match self { - Self::Aes128Cbc | Self::Aes256Cbc => 32, - Self::Aes128Gcm | Self::Aes256Gcm => 28, + Self::Aes128Cbc | Self::Aes256Cbc => Some(16), + #[cfg(feature = "legacy-algorithms")] + Self::Aes192Cbc => Some(16), + #[cfg(feature = "legacy-algorithms")] + Self::TripleDesCbc => Some(8), + _ => None, } } /// Exact wire length produced when encrypting the given plaintext length. pub(crate) fn ciphertext_len_for_plaintext(self, plaintext_len: usize) -> Option { - match self { - Self::Aes128Cbc | Self::Aes256Cbc => (plaintext_len / 16) + match self.cbc_block_len() { + Some(block) => (plaintext_len / block) .checked_add(1)? - .checked_mul(16)? - .checked_add(16), - Self::Aes128Gcm | Self::Aes256Gcm => plaintext_len.checked_add(28), + .checked_mul(block)? + .checked_add(block), + None => plaintext_len.checked_add(28), } } } @@ -94,9 +150,10 @@ pub(crate) fn validate_ciphertext_framing( ) -> Result<(), XmlEncError> { let minimum = algorithm.minimum_ciphertext_len(); if ciphertext_len < minimum { - let algorithm_name = match algorithm { - DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc => "AES-CBC", - DataEncryptionAlgorithm::Aes128Gcm | DataEncryptionAlgorithm::Aes256Gcm => "AES-GCM", + let algorithm_name = match algorithm.cbc_block_len() { + Some(8) => "Triple DES CBC", + Some(_) => "AES-CBC", + None => "AES-GCM", }; return Err(XmlEncError::DataTooShort { algorithm: algorithm_name, @@ -104,22 +161,34 @@ pub(crate) fn validate_ciphertext_framing( actual: ciphertext_len, }); } - if matches!( - algorithm, - DataEncryptionAlgorithm::Aes128Cbc | DataEncryptionAlgorithm::Aes256Cbc - ) && !(ciphertext_len - 16).is_multiple_of(16) + if let Some(block) = algorithm.cbc_block_len() + && !(ciphertext_len - block).is_multiple_of(block) { - return Err(XmlEncError::InvalidCbcCiphertextLength(ciphertext_len - 16)); + return Err(XmlEncError::InvalidCbcCiphertextLength { + algorithm, + block, + actual: ciphertext_len - block, + }); } Ok(()) } impl KeyTransportAlgorithm { + /// Historical transport is never enabled merely by compiling its primitive. + pub const fn requires_explicit_permission(self) -> bool { + match self { + #[cfg(feature = "legacy-algorithms")] + Self::RsaPkcs1v15 => true, + _ => false, + } + } /// Parse a supported XMLEnc key-transport URI. pub fn from_uri(uri: &str) -> Result { match uri { "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p" => Ok(Self::RsaOaepMgf1p), "http://www.w3.org/2009/xmlenc11#rsa-oaep" => Ok(Self::RsaOaep11), + #[cfg(feature = "legacy-algorithms")] + "http://www.w3.org/2001/04/xmlenc#rsa-1_5" => Ok(Self::RsaPkcs1v15), _ => Err(XmlEncError::UnsupportedAlgorithm(uri.to_owned())), } } @@ -129,14 +198,28 @@ impl KeyTransportAlgorithm { match self { Self::RsaOaepMgf1p => "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p", Self::RsaOaep11 => "http://www.w3.org/2009/xmlenc11#rsa-oaep", + #[cfg(feature = "legacy-algorithms")] + Self::RsaPkcs1v15 => "http://www.w3.org/2001/04/xmlenc#rsa-1_5", } } } impl KeyWrapAlgorithm { + /// Family of the wrapping key, not of the wrapped content key. + pub const fn key_kind(self) -> crate::key_manager::SymmetricKeyKind { + #[cfg(feature = "legacy-algorithms")] + if matches!(self, Self::TripleDes) { + return crate::key_manager::SymmetricKeyKind::Des; + } + crate::key_manager::SymmetricKeyKind::Aes + } /// Parse a supported XMLEnc symmetric key-wrap URI. pub fn from_uri(uri: &str) -> Result { match uri { + #[cfg(feature = "legacy-algorithms")] + "http://www.w3.org/2001/04/xmlenc#kw-aes192" => Ok(Self::AesKw192), + #[cfg(feature = "legacy-algorithms")] + "http://www.w3.org/2001/04/xmlenc#kw-tripledes" => Ok(Self::TripleDes), "http://www.w3.org/2001/04/xmlenc#kw-aes128" => Ok(Self::AesKw128), "http://www.w3.org/2001/04/xmlenc#kw-aes256" => Ok(Self::AesKw256), _ => Err(XmlEncError::UnsupportedAlgorithm(uri.to_owned())), @@ -146,6 +229,8 @@ impl KeyWrapAlgorithm { /// Required key-encryption-key length in bytes. pub const fn key_len(self) -> usize { match self { + #[cfg(feature = "legacy-algorithms")] + Self::AesKw192 | Self::TripleDes => 24, Self::AesKw128 => 16, Self::AesKw256 => 32, } @@ -154,15 +239,42 @@ impl KeyWrapAlgorithm { /// Return the standard XMLEnc key-wrap URI. pub const fn uri(self) -> &'static str { match self { + #[cfg(feature = "legacy-algorithms")] + Self::AesKw192 => "http://www.w3.org/2001/04/xmlenc#kw-aes192", + #[cfg(feature = "legacy-algorithms")] + Self::TripleDes => "http://www.w3.org/2001/04/xmlenc#kw-tripledes", Self::AesKw128 => "http://www.w3.org/2001/04/xmlenc#kw-aes128", Self::AesKw256 => "http://www.w3.org/2001/04/xmlenc#kw-aes256", } } + + /// Whether use requires an explicit compiled-policy allowlist entry. + pub const fn requires_explicit_permission(self) -> bool { + match self { + #[cfg(feature = "legacy-algorithms")] + Self::AesKw192 | Self::TripleDes => true, + _ => false, + } + } + + pub(crate) const fn overhead(self) -> usize { + // XMLEnc 1.1 §5.7.1 / RFC 3217 §§2-3: CMS wrapping includes + // an 8-byte checksum and an 8-byte IV, unlike RFC 3394's A register. + // https://www.w3.org/TR/xmlenc-core1/#sec-CMS-3DES + match self { + #[cfg(feature = "legacy-algorithms")] + Self::TripleDes => 16, + _ => 8, + } + } } /// Supported asymmetric session-key transport algorithms. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum KeyTransportAlgorithm { + /// Historical RSAES-PKCS1-v1_5 transport; explicit policy permission is required. + #[cfg(feature = "legacy-algorithms")] + RsaPkcs1v15, /// XML Encryption 1.0 OAEP URI with SHA-1/MGF1-SHA1 absent-field defaults. /// /// An explicit DigestMethod or XMLEnc 1.1 MGF child overrides the corresponding default, @@ -175,6 +287,12 @@ pub enum KeyTransportAlgorithm { /// Supported symmetric key-wrap algorithms. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum KeyWrapAlgorithm { + /// AES-192 RFC 3394 key wrap, explicitly permitted by compatibility policy. + #[cfg(feature = "legacy-algorithms")] + AesKw192, + /// CMS Triple DES key wrap, including XMLEnc's optional non-DES key inputs. + #[cfg(feature = "legacy-algorithms")] + TripleDes, /// RFC 3394 AES key wrap with a 128-bit KEK. AesKw128, /// RFC 3394 AES key wrap with a 256-bit KEK. @@ -334,6 +452,16 @@ impl Default for RsaOaepParameters { /// One recipient of a generated content-encryption key. #[derive(Clone)] pub enum EncryptionRecipient { + /// Historical RSA transport, admitted only by explicit compiled policy. + #[cfg(feature = "legacy-algorithms")] + RsaPkcs1v15 { + /// Opaque RSA public key used by the selected provider. + public_key: Arc, + /// Optional recipient identifier. + recipient: Option, + /// Optional key hint. + key_name: Option, + }, /// Wrap the content key with an RSA public key and OAEP. RsaOaep { /// Opaque recipient public-key handle. @@ -361,6 +489,17 @@ pub enum EncryptionRecipient { impl fmt::Debug for EncryptionRecipient { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + #[cfg(feature = "legacy-algorithms")] + Self::RsaPkcs1v15 { + recipient, + key_name, + .. + } => formatter + .debug_struct("EncryptionRecipient::RsaPkcs1v15") + .field("public_key", &"[PUBLIC KEY]") + .field("recipient", recipient) + .field("key_name", key_name) + .finish(), Self::RsaOaep { parameters, recipient, @@ -390,6 +529,23 @@ impl fmt::Debug for EncryptionRecipient { } impl EncryptionRecipient { + /// Create a historical RSAES-PKCS1-v1_5 recipient. Defaults still deny it. + #[cfg(feature = "legacy-algorithms")] + pub fn rsa_pkcs1v15(public_key: RsaPublicKey) -> Self { + Self::provider_pkcs1v15(Arc::new(crate::provider::RustCryptoRsaPublicKey::new( + public_key, + ))) + } + + /// Create a historical RSA recipient without extracting provider-owned keys. + #[cfg(feature = "legacy-algorithms")] + pub fn provider_pkcs1v15(public_key: Arc) -> Self { + Self::RsaPkcs1v15 { + public_key, + recipient: None, + key_name: None, + } + } /// Create an RSA-OAEP recipient using SHA-256 and MGF1-SHA-256. /// /// XMLEnc 1.1 assigns SHA-1 and MGF1-SHA-1 when these parameters are @@ -436,6 +592,8 @@ impl EncryptionRecipient { /// Set the recipient identifier emitted on `EncryptedKey`. pub fn recipient(mut self, value: impl Into) -> Self { match &mut self { + #[cfg(feature = "legacy-algorithms")] + Self::RsaPkcs1v15 { recipient, .. } => *recipient = Some(value.into()), Self::RsaOaep { recipient, .. } | Self::AesKeyWrap { recipient, .. } => { *recipient = Some(value.into()); } @@ -446,6 +604,8 @@ impl EncryptionRecipient { /// Set the key name emitted inside the encrypted key's `KeyInfo`. pub fn key_name(mut self, value: impl Into) -> Self { match &mut self { + #[cfg(feature = "legacy-algorithms")] + Self::RsaPkcs1v15 { key_name, .. } => *key_name = Some(value.into()), Self::RsaOaep { key_name, .. } | Self::AesKeyWrap { key_name, .. } => { *key_name = Some(value.into()); } @@ -648,9 +808,18 @@ pub enum XmlEncError { /// Actual byte length. actual: usize, }, - /// CBC ciphertext is not a non-empty multiple of the AES block size. - #[error("AES-CBC ciphertext length must be a non-zero multiple of 16 bytes, got {0}")] - InvalidCbcCiphertextLength(usize), + /// CBC ciphertext is not a non-empty multiple of the selected cipher's block size. + #[error( + "{algorithm} ciphertext length must be a non-zero multiple of {block} bytes, got {actual}" + )] + InvalidCbcCiphertextLength { + /// Selected content cipher. + algorithm: DataEncryptionAlgorithm, + /// Cipher's block width in bytes. + block: usize, + /// Ciphertext body width, excluding the IV. + actual: usize, + }, /// XMLEnc random padding is invalid. /// /// No decrypted padding details are exposed. This does not authenticate CBC @@ -748,6 +917,12 @@ impl From for XmlEncError { impl fmt::Display for DataEncryptionAlgorithm { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { + #[cfg(feature = "legacy-algorithms")] + Self::TripleDesCbc => "Triple DES CBC", + #[cfg(feature = "legacy-algorithms")] + Self::Aes192Cbc => "AES-192-CBC", + #[cfg(feature = "legacy-algorithms")] + Self::Aes192Gcm => "AES-192-GCM", Self::Aes128Cbc => "AES-128-CBC", Self::Aes256Cbc => "AES-256-CBC", Self::Aes128Gcm => "AES-128-GCM", @@ -755,3 +930,29 @@ impl fmt::Display for DataEncryptionAlgorithm { }) } } + +#[cfg(test)] +mod framing_tests { + use super::*; + + #[test] + fn cbc_framing_error_names_the_selected_cipher() { + // Diagnostics must identify the actual cipher and its block width, + // rather than reporting AES framing for a DES input. + let error = validate_ciphertext_framing(DataEncryptionAlgorithm::Aes128Cbc, 33) + .expect_err("AES ciphertext body is not block-aligned"); + assert_eq!( + error.to_string(), + "AES-128-CBC ciphertext length must be a non-zero multiple of 16 bytes, got 17" + ); + #[cfg(feature = "legacy-algorithms")] + { + let error = validate_ciphertext_framing(DataEncryptionAlgorithm::TripleDesCbc, 17) + .expect_err("3DES ciphertext body is not block-aligned"); + assert_eq!( + error.to_string(), + "Triple DES CBC ciphertext length must be a non-zero multiple of 8 bytes, got 9" + ); + } + } +} diff --git a/tests/capability_ledger.rs b/tests/capability_ledger.rs index 8eaa63c7..0abd557c 100644 --- a/tests/capability_ledger.rs +++ b/tests/capability_ledger.rs @@ -165,7 +165,7 @@ fn complete_surface_categories_are_stable() { "https://github.com/lsh123/xmlsec" ); assert_eq!(ledger.generated_by, "xml-sec-capability-ledger/2"); - assert_eq!(ledger.classifications.len(), 20); + assert_eq!(ledger.classifications.len(), 21); assert_eq!(ledger.availability.len(), 427); let counts = ledger @@ -349,8 +349,25 @@ fn native_algorithm_claims_match_the_rust_api() { ) }) .collect(); - assert_eq!(claims.len(), 51); + assert_eq!(claims.len(), 64); for item in claims { + if item.classification == "feature-gated-legacy-uri" { + // The ledger records the build requirement, not unconditional API + // availability. Thin/default builds must reject every optional URI. + let uri = item.detail.as_str(); + let available = xml_sec::xmldsig::SignatureAlgorithm::from_uri(uri).is_some() + || xml_sec::xmldsig::DigestAlgorithm::from_uri(uri).is_some() + || xml_sec::xmlenc::DataEncryptionAlgorithm::from_uri(uri).is_ok() + || xml_sec::xmlenc::KeyTransportAlgorithm::from_uri(uri).is_ok() + || xml_sec::xmlenc::KeyWrapAlgorithm::from_uri(uri).is_ok(); + assert_eq!( + available, + cfg!(feature = "legacy-algorithms"), + "{}", + item_id(item) + ); + continue; + } assert_native_uri_support(item); } } @@ -543,6 +560,19 @@ fn legacy_algorithm_claims_are_policy_gated() { "xmlSecHrefEcdsaSha1", "xmlSecHrefHmacSha1", "xmlSecHrefRsaSha1", + "xmlSecHrefAes192Cbc", + "xmlSecHrefAes192Gcm", + "xmlSecHrefKWAes192", + "xmlSecHrefDes3Cbc", + "xmlSecHrefKWDes3", + "xmlSecHrefRsaPkcs1", + "xmlSecHrefMd5", + "xmlSecHrefRipemd160", + "xmlSecHrefRsaMd5", + "xmlSecHrefRsaRipemd160", + "xmlSecHrefHmacMd5", + "xmlSecHrefHmacRipemd160", + "xmlSecHrefEcdsaRipemd160", ]) ); diff --git a/tests/donor_negative_vectors.rs b/tests/donor_negative_vectors.rs index d87693f6..94071d7d 100644 --- a/tests/donor_negative_vectors.rs +++ b/tests/donor_negative_vectors.rs @@ -98,17 +98,25 @@ fn phaos_bad_digest_reports_reference_mismatch_before_key_use() { #[test] fn phaos_bad_signature_artifact_fails_on_its_unsupported_md5_reference() { // Despite its filename, the donor adds a second Reference using MD5 and - // omits DigestValue. Unsupported MD5 is encountered first, so treating - // this artifact as a SignatureValue mismatch would hide malformed input. + // omits DigestValue. Without legacy capability the URI fails first; + // with it, the missing mandatory DigestValue fails structural parsing. let xml = read_vector("signature-rsa-enveloped-bad-sig.xml"); let error = verify_signature_with_pem_key(&xml, STRONG_RSA_PUBLIC_KEY, false) .expect_err("unsupported donor Reference must fail processing"); + #[cfg(not(feature = "legacy-algorithms"))] assert!(matches!( error, DsigError::ParseSignedInfo(ParseError::UnsupportedAlgorithm { ref uri }) if uri == "http://www.w3.org/2001/04/xmldsig-more#md5" )); + #[cfg(feature = "legacy-algorithms")] + assert!(matches!( + error, + DsigError::ParseSignedInfo(ParseError::MissingElement { + element: "DigestValue" + }) + )); } #[test] diff --git a/tests/hmac_pipeline.rs b/tests/hmac_pipeline.rs index 93195452..cd0e186a 100644 --- a/tests/hmac_pipeline.rs +++ b/tests/hmac_pipeline.rs @@ -29,13 +29,20 @@ fn every_hmac_algorithm_and_legal_octet_length_round_trips() { let verifying_key = HmacVerificationKey::new(vec![0x42; 32]).unwrap(); let wrong_key = HmacVerificationKey::new(vec![0x43; 32]).unwrap(); for (algorithm, width) in [ + #[cfg(feature = "legacy-algorithms")] + (SignatureAlgorithm::HmacMd5, 128), + #[cfg(feature = "legacy-algorithms")] + (SignatureAlgorithm::HmacRipemd160, 160), (SignatureAlgorithm::HmacSha1, 160), (SignatureAlgorithm::HmacSha224, 224), (SignatureAlgorithm::HmacSha256, 256), (SignatureAlgorithm::HmacSha384, 384), (SignatureAlgorithm::HmacSha512, 512), ] { - let mut policy = VerificationPolicy::default(); + let mut policy = VerificationPolicy { + signature_algorithms: Some(std::collections::HashSet::from([algorithm])), + ..VerificationPolicy::default() + }; policy .key_trust .allowed_legacy_signature_algorithms @@ -158,12 +165,19 @@ fn all_hmac_methods_interoperate_bidirectionally_with_xmlsec1() { (SignatureAlgorithm::HmacSha256, 128, 256), (SignatureAlgorithm::HmacSha384, 192, 384), (SignatureAlgorithm::HmacSha512, 256, 512), + #[cfg(feature = "legacy-algorithms")] + (SignatureAlgorithm::HmacMd5, 128, 128), + #[cfg(feature = "legacy-algorithms")] + (SignatureAlgorithm::HmacRipemd160, 128, 160), ] { let signing_policy = SigningPolicy { signature_algorithms: Some(std::collections::HashSet::from([algorithm])), ..SigningPolicy::default() }; - let mut verification_policy = VerificationPolicy::default(); + let mut verification_policy = VerificationPolicy { + signature_algorithms: Some([algorithm].into()), + ..VerificationPolicy::default() + }; verification_policy .key_trust .allowed_legacy_signature_algorithms diff --git a/tests/install_xmlsec1.rs b/tests/install_xmlsec1.rs index 615931d7..54878860 100644 --- a/tests/install_xmlsec1.rs +++ b/tests/install_xmlsec1.rs @@ -80,7 +80,24 @@ impl InstallHarness { root.tool("nproc", "#!/bin/sh\nprintf '1\\n'\n"); root.tool( "make", - "#!/bin/sh\nfor arg in \"$@\"; do\n case \"$arg\" in DESTDIR=*) dest=${arg#DESTDIR=} ;; esac\ndone\nif [ -n \"${dest:-}\" ]; then\n mkdir -p \"$dest$XMLSEC1_PREFIX/bin\"\n printf '#!/bin/sh\\nprintf \"%%s\\\\n\" \"${XMLSEC1_SMOKE_OUTPUT-xmlsec1 1.3.13 (openssl)}\"\\nexit \"${XMLSEC1_SMOKE_EXIT:-0}\"\\n' > \"$dest$XMLSEC1_PREFIX/bin/xmlsec1\"\n chmod +x \"$dest$XMLSEC1_PREFIX/bin/xmlsec1\"\nfi\n", + r#"#!/bin/sh +for arg in "$@"; do + case "$arg" in DESTDIR=*) dest=${arg#DESTDIR=} ;; esac +done +if [ -n "${dest:-}" ]; then + mkdir -p "$dest$XMLSEC1_PREFIX/bin" + cat > "$dest$XMLSEC1_PREFIX/bin/xmlsec1" <<'ORACLE' +#!/bin/sh +if [ "$1" = "--list-transforms" ]; then + printf '%s\n' '"md5","ripemd160","rsa-md5","rsa-ripemd160","hmac-md5","hmac-ripemd160","ecdsa-ripemd160"' + exit 0 +fi +printf '%s\n' "${XMLSEC1_SMOKE_OUTPUT-xmlsec1 1.3.13 (openssl)}" +exit "${XMLSEC1_SMOKE_EXIT:-0}" +ORACLE + chmod +x "$dest$XMLSEC1_PREFIX/bin/xmlsec1" +fi +"#, ); root.tool( "mv", diff --git a/tests/modern_algorithms.rs b/tests/modern_algorithms.rs index 7f718c08..c77c9a30 100644 --- a/tests/modern_algorithms.rs +++ b/tests/modern_algorithms.rs @@ -1038,6 +1038,8 @@ fn every_ecdsa_curve_and_modern_digest_runs_the_full_pipeline() { ]; for (curve, key) in keys.iter().enumerate() { for algorithm in [ + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::EcdsaRipemd160, SignatureAlgorithm::EcdsaSha1, SignatureAlgorithm::EcdsaSha224, SignatureAlgorithm::EcdsaSha256, @@ -1054,7 +1056,9 @@ fn every_ecdsa_curve_and_modern_digest_runs_the_full_pipeline() { }; // Legacy capability needs explicit policy permission; the matrix // includes SHA-1 without weakening the default secure profile. - if algorithm == SignatureAlgorithm::EcdsaSha1 { + if algorithm == SignatureAlgorithm::EcdsaSha1 + || algorithm.requires_explicit_permission() + { assert!( SignContext::new(key.as_ref()) .sign_template(&template(algorithm)) @@ -1219,7 +1223,15 @@ fn assert_bidirectional_xmlsec1( let template_path = directory.path().join("template.xml"); let signed_path = directory.path().join("signed.xml"); let oracle_path = directory.path().join("oracle.xml"); - let signed = SignContext::new(key).sign_template(&xml).unwrap(); + let signing_policy = xml_sec::policy::SigningPolicy { + signature_algorithms: Some([algorithm].into()), + digest_algorithms: Some(xml_sec::xmldsig::DigestAlgorithm::ALL.into()), + ..xml_sec::policy::SigningPolicy::default() + }; + let signed = SignContext::new(key) + .policy(signing_policy) + .sign_template(&xml) + .unwrap(); std::fs::write(&signed_path, signed).unwrap(); let verified = xmlsec1::command() .args(["verify", "--lax-key-search", "--privkey-pem"]) @@ -1256,6 +1268,11 @@ fn assert_bidirectional_xmlsec1( assert_eq!( VerifyContext::new() .key(&verifier) + .policy(xml_sec::policy::VerificationPolicy { + signature_algorithms: Some([algorithm].into()), + digest_algorithms: Some(xml_sec::xmldsig::DigestAlgorithm::ALL.into()), + ..xml_sec::policy::VerificationPolicy::default() + }) .verify(&signed) .unwrap() .status, @@ -1263,6 +1280,38 @@ fn assert_bidirectional_xmlsec1( ); } +#[cfg(feature = "legacy-algorithms")] +#[test] +fn historical_rsa_and_ecdsa_interoperate_bidirectionally() { + // Both directions detect shared signer/verifier defects, including legacy + // reference digests independently selected from the signature method. + use xml_sec::xmldsig::{ + DigestAlgorithm, EcdsaP256SigningKey, RsaSigningKey, SignatureAlgorithm, + }; + if !xmlsec1::is_available() { + eprintln!("{}", xmlsec1::skip_reason()); + return; + } + let rsa_path = std::path::Path::new("tests/fixtures/keys/rsa/rsa-2048-key.pem"); + let rsa = RsaSigningKey::from_pkcs8_pem(&std::fs::read_to_string(rsa_path).unwrap()).unwrap(); + for (algorithm, digest) in [ + (SignatureAlgorithm::RsaMd5, DigestAlgorithm::Md5), + (SignatureAlgorithm::RsaRipemd160, DigestAlgorithm::Ripemd160), + ] { + let xml = template(algorithm).replace(DigestAlgorithm::Sha256.uri(), digest.uri()); + assert_bidirectional_xmlsec1(&rsa, rsa_path, algorithm, xml); + } + let ec_path = std::path::Path::new("tests/fixtures/keys/ec/ec-prime256v1-key.pem"); + let ec = + EcdsaP256SigningKey::from_pkcs8_pem(&std::fs::read_to_string(ec_path).unwrap()).unwrap(); + assert_bidirectional_xmlsec1( + &ec, + ec_path, + SignatureAlgorithm::EcdsaRipemd160, + template(SignatureAlgorithm::EcdsaRipemd160), + ); +} + #[test] fn all_eddsa_methods_interoperate_with_xmlsec1() { use xml_sec::c14n::{C14nAlgorithm, C14nMode}; diff --git a/tests/phaos_interop.rs b/tests/phaos_interop.rs index 8b632021..fd1b8c67 100644 --- a/tests/phaos_interop.rs +++ b/tests/phaos_interop.rs @@ -16,6 +16,7 @@ const PHAOS: &str = "tests/fixtures/xmldsig/phaos-xmldsig-three"; const RFC3161: &str = "tests/fixtures/xmldsig/external-data/rfc3161.txt"; const VERIFY_2009: u64 = 1_230_804_000; const XSLT_URI: &str = "http://www.w3.org/TR/1999/REC-xslt-19991116"; +#[cfg(not(feature = "legacy-algorithms"))] const MD5_URI: &str = "http://www.w3.org/2001/04/xmldsig-more#md5"; const HMAC_MD5_URI: &str = "http://www.w3.org/2001/04/xmldsig-more#hmac-md5"; @@ -30,9 +31,13 @@ enum Setup { #[derive(Clone, Copy, Debug)] enum Expected { - Valid { manifest_references: usize }, + Valid { + manifest_references: usize, + }, ValidWithManifestFailure, BadDigest, + #[cfg(feature = "legacy-algorithms")] + MissingDigestValue, UnsupportedSignature(&'static str), UnsupportedTransform(&'static str), UnsupportedCertificateSignature(&'static str), @@ -157,7 +162,10 @@ const CASES: &[Case] = &[ Case { name: "signature-rsa-enveloped-bad-sig.xml", setup: Setup::Rsa, + #[cfg(not(feature = "legacy-algorithms"))] expected: Expected::UnsupportedSignature(MD5_URI), + #[cfg(feature = "legacy-algorithms")] + expected: Expected::MissingDigestValue, }, Case { name: "signature-rsa-enveloped.xml", @@ -399,10 +407,29 @@ fn check_expected( result.status == DsigStatus::Invalid(FailureReason::ReferenceDigestMismatch { ref_index: 0 }) } + #[cfg(feature = "legacy-algorithms")] + ( + Expected::MissingDigestValue, + Err(DsigError::ParseSignedInfo(ParseError::MissingElement { + element: "DigestValue", + })), + ) => true, ( Expected::UnsupportedSignature(uri), Err(DsigError::ParseSignedInfo(ParseError::UnsupportedAlgorithm { uri: actual })), ) => actual == uri, + #[cfg(feature = "legacy-algorithms")] + ( + Expected::UnsupportedSignature(uri), + Err(DsigError::Policy(xml_sec::policy::PolicyViolation::Algorithm { + operation: "verification", + algorithm, + })), + ) => { + algorithm == uri + && SignatureAlgorithm::from_uri(uri) + .is_some_and(|method| method.requires_explicit_permission()) + } ( Expected::UnsupportedTransform(uri), Err(DsigError::ParseSignedInfo(ParseError::Transform( diff --git a/tests/signing_digest.rs b/tests/signing_digest.rs index cc2a0cd9..9838eb37 100644 --- a/tests/signing_digest.rs +++ b/tests/signing_digest.rs @@ -3139,6 +3139,83 @@ fn explicit_legacy_sha1_policy_builds_signs_and_verifies() { assert_eq!(result.status, DsigStatus::Valid); } +#[cfg(feature = "legacy-algorithms")] +#[test] +fn historical_rsa_methods_require_independent_digest_permission() { + // Optional primitives do not weaken either policy: the signature and each + // reference digest require explicit permission, including KeyValue resolution. + let key = + RsaSigningKey::from_pkcs8_pem(&read_fixture("tests/fixtures/keys/rsa/rsa-2048-key.pem")) + .unwrap(); + for (method, digest) in [ + (SignatureAlgorithm::RsaMd5, DigestAlgorithm::Md5), + (SignatureAlgorithm::RsaRipemd160, DigestAlgorithm::Ripemd160), + ] { + let builder = SignatureBuilder::new(exclusive_c14n(), method) + .add_reference( + ReferenceBuilder::new(digest) + .uri("#payload") + .transform(Transform::C14n(exclusive_c14n())), + ) + .key_info(true); + assert!(builder.build_template().is_err()); + let source = "historical interop"; + let mut signing = SigningPolicy { + signature_algorithms: Some(HashSet::from([method])), + ..SigningPolicy::default() + }; + assert!( + SignContext::new(&key) + .policy(signing.clone()) + .sign_with_builder(source, &builder) + .is_err() + ); + signing.digest_algorithms = Some(HashSet::from([digest])); + let signed = SignContext::new(&key) + .policy(signing) + .key_info_writer(&KeyValueInfoWriter) + .sign_with_builder(source, &builder) + .unwrap(); + let resolver = DefaultKeyResolver::default(); + assert!( + VerifyContext::new() + .key_resolver(&resolver) + .verify(&signed) + .is_err() + ); + let mut verification = VerificationPolicy::default(); + verification.key_trust.mode = xml_sec::policy::VerificationTrustMode::CryptographicOnly; + verification.signature_algorithms = Some(HashSet::from([method])); + assert!( + VerifyContext::new() + .policy(verification.clone()) + .key_resolver(&resolver) + .verify(&signed) + .is_err() + ); + verification.digest_algorithms = Some(HashSet::from([digest])); + assert_eq!( + VerifyContext::new() + .policy(verification.clone()) + .key_resolver(&resolver) + .verify(&signed) + .unwrap() + .status, + DsigStatus::Valid + ); + let altered = signed.replace("historical interop", "altered interop"); + assert_ne!( + VerifyContext::new() + .policy(verification) + .key_resolver(&resolver) + .verify(&altered) + .unwrap() + .status, + DsigStatus::Valid + ); + } +} + #[test] fn signs_ecdsa_donor_templates_and_verifies_round_trip() { // The donor enveloped ECDSA templates include an XPath transform, which is diff --git a/tests/xmlenc_donor_integration.rs b/tests/xmlenc_donor_integration.rs index 7f36f7f8..c6616853 100644 --- a/tests/xmlenc_donor_integration.rs +++ b/tests/xmlenc_donor_integration.rs @@ -252,9 +252,25 @@ fn classifies_complete_nist_aes_gcm_corpus() { let key_name = vector_key_name(&xml); let key = keys.get(&key_name).expect("NIST vector key must exist"); let data_path = xml_path.with_extension("data"); - let result = decrypt(&xml, &SymmetricKeyDecryptor::new(key.clone())); + let resolver = SymmetricKeyDecryptor::new(key.clone()); + #[cfg(feature = "legacy-algorithms")] + let result = DecryptContext::new(&resolver) + .policy(xml_sec::policy::DecryptionPolicy { + data_algorithms: Some( + [ + xml_sec::xmlenc::DataEncryptionAlgorithm::Aes128Gcm, + xml_sec::xmlenc::DataEncryptionAlgorithm::Aes192Gcm, + xml_sec::xmlenc::DataEncryptionAlgorithm::Aes256Gcm, + ] + .into(), + ), + ..xml_sec::policy::DecryptionPolicy::default() + }) + .decrypt(&xml); + #[cfg(not(feature = "legacy-algorithms"))] + let result = decrypt(&xml, &resolver); - if bits == 192 { + if bits == 192 && !cfg!(feature = "legacy-algorithms") { assert!( matches!(result, Err(XmlEncError::UnsupportedAlgorithm(_))), "{}", @@ -290,7 +306,14 @@ fn classifies_complete_nist_aes_gcm_corpus() { rejected > 0, "corpus must contain invalid supported vectors" ); - assert!(unsupported > 0, "corpus must account for AES-192 vectors"); + if cfg!(feature = "legacy-algorithms") { + assert_eq!( + unsupported, 0, + "all AES-192 vectors must execute with permission" + ); + } else { + assert!(unsupported > 0, "corpus must account for AES-192 vectors"); + } assert_eq!( supported + rejected + unsupported, 180, @@ -443,6 +466,98 @@ fn decrypts_supported_phaos_rsa_oaep_and_aes_kw_vectors() { } } +#[cfg(feature = "legacy-algorithms")] +#[test] +fn decrypts_all_phaos_legacy_transport_and_wrap_vectors() { + use xml_sec::policy::DecryptionPolicy; + use xml_sec::xmlenc::{ + DataEncryptionAlgorithm as D, KeyTransportAlgorithm as T, KeyWrapAlgorithm as W, + }; + // Independent donor bytes cover all newly implemented content/transport/wrap + // mechanisms, not ciphertext produced by the same implementation under test. + let mut policy = DecryptionPolicy { + data_algorithms: Some([D::TripleDesCbc, D::Aes128Cbc, D::Aes192Cbc, D::Aes256Cbc].into()), + key_transport_algorithms: Some([T::RsaPkcs1v15, T::RsaOaepMgf1p].into()), + key_wrap_algorithms: Some([W::TripleDes, W::AesKw128, W::AesKw192, W::AesKw256].into()), + ..DecryptionPolicy::default() + }; + policy.rsa_keys.minimum_modulus_bits = 1024; + let private = PrivateKeyDecryptor::new(read_phaos_private_key()); + let keys_xml = std::fs::read_to_string(format!("{PHAOS_DIR}/keys.xml")).unwrap(); + let keys_doc = Document::parse(&keys_xml).unwrap(); + let mut keys: HashMap<_, _> = read_aes_keys(Path::new(&format!("{PHAOS_DIR}/keys.xml"))) + .into_iter() + .map(|(name, bytes)| (name, (xml_sec::key_manager::SymmetricKeyKind::Aes, bytes))) + .collect(); + for entry in keys_doc + .descendants() + .filter(|node| node.tag_name().name() == "KeyInfo") + { + if let Some(value) = entry + .descendants() + .find(|node| node.tag_name().name() == "DESKeyValue") + { + let name = entry + .descendants() + .find(|node| node.tag_name().name() == "KeyName") + .unwrap() + .text() + .unwrap(); + keys.insert( + name.into(), + ( + xml_sec::key_manager::SymmetricKeyKind::Des, + STANDARD + .decode( + value + .text() + .unwrap() + .split_ascii_whitespace() + .collect::(), + ) + .unwrap(), + ), + ); + } + } + for (name, key_name) in [ + ("enc-element-3des-kt-rsa1_5", None), + ("enc-element-3des-kt-rsa_oaep_sha1", None), + ("enc-element-3des-kt-rsa_oaep_sha256", None), + ("enc-element-3des-kt-rsa_oaep_sha512", None), + ("enc-element-aes192-kt-rsa_oaep_sha1", None), + ("enc-text-aes192-kt-rsa1_5", None), + ("enc-content-aes256-kt-rsa1_5", None), + ("enc-element-aes128-kt-rsa1_5", None), + ("enc-content-3des-kw-aes192", Some("my-aes192-key")), + ("enc-element-3des-kw-3des", Some("my-tripledes-key")), + ("enc-text-3des-kw-aes256", Some("my-aes256-key")), + ("enc-content-aes192-kw-aes256", Some("my-aes256-key")), + ("enc-element-aes192-kw-aes192", Some("my-aes192-key")), + ("enc-content-aes128-kw-3des", Some("my-3des-key")), + ("enc-text-aes128-kw-aes192", Some("my-aes192-key")), + ] { + let encrypted = std::fs::read_to_string(format!("{PHAOS_DIR}/{name}.xml")).unwrap(); + let expected = std::fs::read(format!("{PHAOS_DIR}/{name}.data")).unwrap(); + let resolver: Box = match key_name { + None => Box::new(private.clone()), + Some(name) => { + let (kind, bytes) = keys.get(name).unwrap(); + Box::new(KekDecryptor::with_kind(bytes.clone(), *kind)) + } + }; + let actual = DecryptContext::new(resolver.as_ref()) + .policy(policy.clone()) + .decrypt_document(&encrypted, Some("ED")) + .unwrap_or_else(|error| panic!("{name}: {error}")); + assert_eq!( + canonicalize_fixture_document(actual.as_bytes()), + canonicalize_fixture_document(&expected), + "{name}" + ); + } +} + /// Asserts that a donor vector fails at its explicitly classified algorithm URI. fn assert_unsupported( name: &str, @@ -452,6 +567,17 @@ fn assert_unsupported( let xml = std::fs::read_to_string(format!("{PHAOS_DIR}/{name}.xml")) .expect("tracked Phaos vector must be readable"); let result = decrypt_document(&xml, Some("ED"), resolver); + #[cfg(feature = "legacy-algorithms")] + if xml_sec::xmlenc::DataEncryptionAlgorithm::from_uri(expected_uri).is_ok() + || xml_sec::xmlenc::KeyWrapAlgorithm::from_uri(expected_uri).is_ok() + || xml_sec::xmlenc::KeyTransportAlgorithm::from_uri(expected_uri).is_ok() + { + assert!( + matches!(&result, Err(XmlEncError::Policy(xml_sec::policy::PolicyViolation::Algorithm { operation: "decryption", algorithm })) if algorithm == expected_uri), + "{name} must deny unpermitted {expected_uri}, got {result:?}" + ); + return; + } assert!( matches!(&result, Err(XmlEncError::UnsupportedAlgorithm(uri)) if uri == expected_uri), "{name} must reject {expected_uri}, got {result:?}" diff --git a/tests/xmlenc_encrypt_integration.rs b/tests/xmlenc_encrypt_integration.rs index 950f8c09..37c98ba0 100644 --- a/tests/xmlenc_encrypt_integration.rs +++ b/tests/xmlenc_encrypt_integration.rs @@ -23,6 +23,261 @@ const RSA_2048_PUBLIC: &str = "tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"; const RSA_4096_PRIVATE: &str = "tests/fixtures/keys/rsa/rsa-4096-key.pem"; const RSA_4096_PUBLIC: &str = "tests/fixtures/keys/rsa/rsa-4096-pubkey.pem"; +#[cfg(feature = "legacy-algorithms")] +#[test] +fn direct_aes_key_cannot_be_used_for_tripledes() { + use xml_sec::xmlenc::DecryptionKeyResolver; + // Equal byte lengths are not algorithm identity. The legacy constructor + // declares AES, and must reject untrusted selection of TripleDES. + assert!( + SymmetricKeyDecryptor::new([0x31; 24]) + .resolve_key( + xml_sec::provider::default_provider(), + DataEncryptionAlgorithm::TripleDesCbc, + None + ) + .is_err() + ); +} + +#[cfg(feature = "legacy-algorithms")] +#[test] +fn aes_kek_cannot_be_used_for_tripledes_wrap() { + use xml_sec::policy::EncryptionPolicy; + use xml_sec::xmlenc::DecryptionKeyResolver; + // A permitted wrap algorithm cannot redefine the trusted KEK's family. + let kek = vec![0x31; 24]; + let encrypted = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .add_recipient(EncryptionRecipient::aes_key_wrap( + kek.clone(), + KeyWrapAlgorithm::TripleDes, + )) + .policy(EncryptionPolicy { + key_wrap_algorithms: Some([KeyWrapAlgorithm::TripleDes].into()), + ..EncryptionPolicy::default() + }) + .encrypt_binary(b"KEK family boundary") + .unwrap(); + let parsed = parse_encrypted_data(&encrypted.encrypted_data_xml).unwrap(); + assert!(matches!( + KekDecryptor::new(kek.clone()).resolve_key( + xml_sec::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + parsed.encrypted_keys.first(), + ), + Err(XmlEncError::KeyNotFound) + )); + assert!( + KekDecryptor::with_kind(kek, xml_sec::key_manager::SymmetricKeyKind::Des) + .resolve_key( + xml_sec::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + parsed.encrypted_keys.first(), + ) + .is_ok() + ); +} + +#[cfg(feature = "legacy-algorithms")] +#[test] +fn direct_resolvers_enforce_legacy_method_permissions() { + use xml_sec::policy::{DecryptionPolicy, EncryptionPolicy}; + use xml_sec::xmlenc::{DecryptionKeyResolver, KeyCandidateBudget}; + // Direct public resolver calls must enforce the same permission as the + // enclosing context, before decoding or dispatching a denied recipient. + for wrap in [KeyWrapAlgorithm::AesKw192, KeyWrapAlgorithm::TripleDes] { + let kek = vec![0x33; wrap.key_len()]; + let encrypted = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .add_recipient(EncryptionRecipient::aes_key_wrap(kek.clone(), wrap)) + .policy(EncryptionPolicy { + key_wrap_algorithms: Some([wrap].into()), + ..EncryptionPolicy::default() + }) + .encrypt_binary(b"policy boundary") + .unwrap(); + let parsed = parse_encrypted_data(&encrypted.encrypted_data_xml).unwrap(); + let resolver = KekDecryptor::with_kind(kek, wrap.key_kind()); + assert!(matches!( + resolver.resolve_key_candidates_with_policy( + xml_sec::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + parsed.encrypted_keys.first(), + &DecryptionPolicy::default(), + &mut KeyCandidateBudget::for_operation() + ), + Err(XmlEncError::Policy(_)) + )); + } + let encrypted = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .add_recipient(EncryptionRecipient::rsa_pkcs1v15(public_key( + RSA_2048_PUBLIC, + ))) + .policy(EncryptionPolicy { + key_transport_algorithms: Some([KeyTransportAlgorithm::RsaPkcs1v15].into()), + ..EncryptionPolicy::default() + }) + .encrypt_binary(b"policy boundary") + .unwrap(); + let parsed = parse_encrypted_data(&encrypted.encrypted_data_xml).unwrap(); + assert!(matches!( + PrivateKeyDecryptor::new(private_key(RSA_2048_PRIVATE)).resolve_key_with_policy( + xml_sec::provider::default_provider(), + DataEncryptionAlgorithm::Aes128Gcm, + parsed.encrypted_keys.first(), + &DecryptionPolicy::default() + ), + Err(XmlEncError::Policy(_)) + )); +} + +#[cfg(feature = "legacy-algorithms")] +#[test] +fn pkcs1v15_transport_is_parameterless_and_explicitly_permitted() { + use xml_sec::policy::{DecryptionPolicy, EncryptionPolicy}; + use xml_sec::xmlenc::DecryptContext; + // Transport permission is independent on both sides. No OAEP digest/MGF + // defaults may leak into a parameterless RSA-1.5 EncryptionMethod. + let public = public_key(RSA_2048_PUBLIC); + let recipient = EncryptionRecipient::rsa_pkcs1v15(public); + assert!( + EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .add_recipient(recipient.clone()) + .encrypt_binary(b"legacy transport") + .is_err() + ); + let transport = KeyTransportAlgorithm::RsaPkcs1v15; + let encrypted = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .add_recipient(recipient) + .policy(EncryptionPolicy { + key_transport_algorithms: Some([transport].into()), + ..EncryptionPolicy::default() + }) + .encrypt_binary(b"legacy transport") + .unwrap(); + assert!(!encrypted.encrypted_data_xml.contains("OAEPparams")); + assert!(!encrypted.encrypted_data_xml.contains("DigestMethod")); + let resolver = PrivateKeyDecryptor::new(private_key(RSA_2048_PRIVATE)); + assert!(decrypt(&encrypted.encrypted_data_xml, &resolver).is_err()); + let policy = DecryptionPolicy { + key_transport_algorithms: Some([transport].into()), + ..DecryptionPolicy::default() + }; + assert_eq!( + DecryptContext::new(&resolver) + .policy(policy.clone()) + .decrypt(&encrypted.encrypted_data_xml) + .unwrap(), + DecryptedContent::Bytes(b"legacy transport".to_vec()) + ); + // Implicit RSA padding rejection must not release plaintext with a wrong + // key; authenticated content checks the recovered/fallback CEK uniformly. + let wrong_key = PrivateKeyDecryptor::new(private_key(RSA_4096_PRIVATE)); + assert!( + DecryptContext::new(&wrong_key) + .policy(policy.clone()) + .decrypt(&encrypted.encrypted_data_xml) + .is_err() + ); + let document = roxmltree::Document::parse(&encrypted.encrypted_data_xml).unwrap(); + let wrapped = document + .descendants() + .find(|node| node.tag_name().name() == "EncryptedKey") + .unwrap() + .descendants() + .find(|node| node.tag_name().name() == "CipherValue") + .unwrap() + .text() + .unwrap(); + let mut corrupt = STANDARD.decode(wrapped).unwrap(); + corrupt.fill(0); + let damaged = encrypted + .encrypted_data_xml + .replace(wrapped, &STANDARD.encode(&corrupt)); + assert!( + DecryptContext::new(&resolver) + .policy(policy.clone()) + .decrypt(&damaged) + .is_err() + ); + let short = encrypted + .encrypted_data_xml + .replace(wrapped, &STANDARD.encode(&corrupt[..corrupt.len() - 1])); + // An out-of-range RSA representative must reach the same authenticated + // content check as a padding failure, never a distinct provider error. + corrupt.fill(0xff); + let out_of_range = encrypted + .encrypted_data_xml + .replace(wrapped, &STANDARD.encode(&corrupt)); + assert!(matches!( + DecryptContext::new(&resolver) + .policy(policy.clone()) + .decrypt(&out_of_range), + Err(XmlEncError::AeadAuthenticationFailed) + )); + assert!( + DecryptContext::new(&resolver) + .policy(policy.clone()) + .decrypt(&short) + .is_err() + ); + let malformed = encrypted.encrypted_data_xml.replace( + &format!("Algorithm=\"{}\"/>", transport.uri()), + &format!( + "Algorithm=\"{}\">AA==", + transport.uri() + ), + ); + assert_ne!(malformed, encrypted.encrypted_data_xml); + assert!( + DecryptContext::new(&resolver) + .policy(policy) + .decrypt(&malformed) + .is_err() + ); +} + +#[cfg(feature = "legacy-algorithms")] +#[test] +fn legacy_content_requires_permission_on_both_public_paths() { + use xml_sec::policy::{DecryptionPolicy, EncryptionPolicy}; + use xml_sec::xmlenc::DecryptContext; + // Compiling a mechanism must not grant permission. Both public operation + // boundaries independently enforce the immutable allowlist snapshot. + for algorithm in [ + DataEncryptionAlgorithm::TripleDesCbc, + DataEncryptionAlgorithm::Aes192Cbc, + DataEncryptionAlgorithm::Aes192Gcm, + ] { + let key = [0x31; 24]; + assert!( + EncryptedDataBuilder::new(algorithm) + .direct_key(key) + .encrypt_binary(b"secret") + .is_err() + ); + let encrypted = EncryptedDataBuilder::new(algorithm) + .direct_key(key) + .policy(EncryptionPolicy { + data_algorithms: Some([algorithm].into()), + ..EncryptionPolicy::default() + }) + .encrypt_binary(b"secret") + .unwrap(); + let resolver = SymmetricKeyDecryptor::with_kind(key, algorithm.key_kind()); + assert!(decrypt(&encrypted.encrypted_data_xml, &resolver).is_err()); + assert_eq!( + DecryptContext::new(&resolver) + .policy(DecryptionPolicy { + data_algorithms: Some([algorithm].into()), + ..DecryptionPolicy::default() + }) + .decrypt(&encrypted.encrypted_data_xml) + .unwrap(), + DecryptedContent::Bytes(b"secret".to_vec()) + ); + } +} + fn private_key(path: &str) -> RsaPrivateKey { RsaPrivateKey::from_pkcs8_pem(&fs::read_to_string(path).expect("RSA fixture must load")) .expect("RSA fixture must contain a PKCS#8 private key") diff --git a/tests/xmlenc_encrypt_xmlsec1.rs b/tests/xmlenc_encrypt_xmlsec1.rs index 65f31343..a14e1803 100644 --- a/tests/xmlenc_encrypt_xmlsec1.rs +++ b/tests/xmlenc_encrypt_xmlsec1.rs @@ -114,6 +114,94 @@ fn xmlsec1_decrypts_direct_aes_gcm_from_xml_sec() { ); } +#[cfg(feature = "legacy-algorithms")] +#[test] +fn xmlsec1_decrypts_all_optional_content_and_transport_methods() { + // Independent libxmlsec1 checks the 8-byte TDEA framing, AES-192 nonce/tag + // layout, and parameterless RSA-1.5 transport, not just our own round trips. + use xml_sec::policy::EncryptionPolicy; + use xml_sec::xmlenc::KeyTransportAlgorithm; + if !xmlsec1::is_available() { + eprintln!("{}", xmlsec1::skip_reason()); + return; + } + let plaintext = b"optional legacy mechanism interoperability"; + for algorithm in [ + DataEncryptionAlgorithm::TripleDesCbc, + DataEncryptionAlgorithm::Aes192Cbc, + DataEncryptionAlgorithm::Aes192Gcm, + ] { + let key = [0x31; 24]; + let encrypted = EncryptedDataBuilder::new(algorithm) + .direct_key(key) + .direct_key_name("interop") + .policy(EncryptionPolicy { + data_algorithms: Some([algorithm].into()), + ..EncryptionPolicy::default() + }) + .encrypt_binary(plaintext) + .expect("explicitly permitted encryption"); + let file = TemporaryFile::write("legacy-key", "bin", &key); + let option = if algorithm == DataEncryptionAlgorithm::TripleDesCbc { + "--deskey:interop" + } else { + "--aeskey:interop" + }; + assert_eq!( + decrypt_with_xmlsec1(&encrypted.encrypted_data_xml, option, &file.path), + plaintext + ); + } + for algorithm in [ + xml_sec::xmlenc::KeyWrapAlgorithm::AesKw192, + xml_sec::xmlenc::KeyWrapAlgorithm::TripleDes, + ] { + // Verify emitted CMS and AES-192 key-wrap bytes with the independent + // resolver, complementing imported donor ciphertext decryption. + let key = [0x31; 24]; + let encrypted = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .add_recipient( + EncryptionRecipient::aes_key_wrap(key, algorithm).key_name("interop-wrap"), + ) + .policy(EncryptionPolicy { + key_wrap_algorithms: Some([algorithm].into()), + ..EncryptionPolicy::default() + }) + .encrypt_binary(plaintext) + .expect("permitted wrapping"); + let file = TemporaryFile::write("legacy-kek", "bin", &key); + let option = if algorithm == xml_sec::xmlenc::KeyWrapAlgorithm::TripleDes { + "--deskey:interop-wrap" + } else { + "--aeskey:interop-wrap" + }; + assert_eq!( + decrypt_with_xmlsec1(&encrypted.encrypted_data_xml, option, &file.path), + plaintext + ); + } + let public = RsaPublicKey::from_public_key_pem( + &fs::read_to_string("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem").expect("public key"), + ) + .expect("RSA SPKI"); + let encrypted = EncryptedDataBuilder::new(DataEncryptionAlgorithm::Aes128Gcm) + .add_recipient(EncryptionRecipient::rsa_pkcs1v15(public).key_name("interop-rsa")) + .policy(EncryptionPolicy { + key_transport_algorithms: Some([KeyTransportAlgorithm::RsaPkcs1v15].into()), + ..EncryptionPolicy::default() + }) + .encrypt_binary(plaintext) + .expect("RSA-1.5 encryption"); + assert_eq!( + decrypt_with_xmlsec1( + &encrypted.encrypted_data_xml, + "--privkey-pem:interop-rsa", + Path::new("tests/fixtures/keys/rsa/rsa-2048-key.pem") + ), + plaintext + ); +} + #[test] fn xmlsec1_decrypts_rsa_oaep_wrapped_aes_cbc_from_xml_sec() { // This covers generated session-key transport, OAEP digest/MGF metadata, diff --git a/tools/xmlsec1/src/args.rs b/tools/xmlsec1/src/args.rs index fafc0d86..6e8e39f4 100644 --- a/tools/xmlsec1/src/args.rs +++ b/tools/xmlsec1/src/args.rs @@ -220,6 +220,8 @@ pub(crate) const OPTION_SPECS: &[OptionSpec] = &[ option_spec!("untrusted-pem", ["untrusted"], VALUE, false, MULTIPLE), option_spec!("untrusted-der", [], VALUE, false, MULTIPLE), option_spec!("aes-key", ["aeskey"], VALUE, true, MULTIPLE), + #[cfg(feature = "legacy-algorithms")] + option_spec!("des-key", ["deskey"], VALUE, true, MULTIPLE), option_spec!("hmac-key", ["hmackey"], VALUE, true, MULTIPLE), option_spec!("pwd", [], VALUE, false, SINGLE), option_spec!("enabled-key-data", [], VALUE, false, MULTIPLE), diff --git a/tools/xmlsec1/src/capabilities.rs b/tools/xmlsec1/src/capabilities.rs index 62206373..80365e52 100644 --- a/tools/xmlsec1/src/capabilities.rs +++ b/tools/xmlsec1/src/capabilities.rs @@ -1,6 +1,32 @@ use std::{ffi::OsString, io::Write}; pub const TRANSFORMS: &[&str] = &[ + #[cfg(feature = "legacy-algorithms")] + "md5", + #[cfg(feature = "legacy-algorithms")] + "ripemd160", + #[cfg(feature = "legacy-algorithms")] + "rsa-md5", + #[cfg(feature = "legacy-algorithms")] + "rsa-ripemd160", + #[cfg(feature = "legacy-algorithms")] + "hmac-md5", + #[cfg(feature = "legacy-algorithms")] + "hmac-ripemd160", + #[cfg(feature = "legacy-algorithms")] + "ecdsa-ripemd160", + #[cfg(feature = "legacy-algorithms")] + "aes192-cbc", + #[cfg(feature = "legacy-algorithms")] + "aes192-gcm", + #[cfg(feature = "legacy-algorithms")] + "tripledes-cbc", + #[cfg(feature = "legacy-algorithms")] + "rsa-1_5", + #[cfg(feature = "legacy-algorithms")] + "kw-aes192", + #[cfg(feature = "legacy-algorithms")] + "kw-tripledes", "base64", "enveloped-signature", "c14n", @@ -152,28 +178,42 @@ pub fn transform_available(name: &str, provider: &dyn xml_sec::provider::CryptoP || provider.supports(C::Verify(algorithm)); } } - for algorithm in [ - D::Sha1, - D::Sha224, - D::Sha256, - D::Sha384, - D::Sha512, - D::Sha3_224, - D::Sha3_256, - D::Sha3_384, - D::Sha3_512, - ] { + for algorithm in D::ALL { if algorithm.uri().rsplit('#').next() == Some(name) { return provider.supports(C::Digest(algorithm)); } } - for algorithm in [E::Aes128Cbc, E::Aes256Cbc, E::Aes128Gcm, E::Aes256Gcm] { + for algorithm in [ + E::Aes128Cbc, + E::Aes256Cbc, + E::Aes128Gcm, + E::Aes256Gcm, + #[cfg(feature = "legacy-algorithms")] + E::Aes192Cbc, + #[cfg(feature = "legacy-algorithms")] + E::Aes192Gcm, + #[cfg(feature = "legacy-algorithms")] + E::TripleDesCbc, + ] { if algorithm.uri().rsplit('#').next() == Some(name) { return provider.supports(C::Encrypt(algorithm)) || provider.supports(C::Decrypt(algorithm)); } } let oaep = match name { + #[cfg(feature = "legacy-algorithms")] + "rsa-1_5" => { + return provider.supports(C::Pkcs1v15Transport) + || provider.supports(C::Pkcs1v15Recovery); + } + #[cfg(feature = "legacy-algorithms")] + "kw-aes192" => { + return provider.supports(C::KeyWrap(xml_sec::xmlenc::KeyWrapAlgorithm::AesKw192)); + } + #[cfg(feature = "legacy-algorithms")] + "kw-tripledes" => { + return provider.supports(C::KeyWrap(xml_sec::xmlenc::KeyWrapAlgorithm::TripleDes)); + } "rsa-oaep-mgf1p" => RsaOaepParameters::default(), "rsa-oaep-enc11" => { RsaOaepParameters::xmlenc11(OaepDigestAlgorithm::Sha256, OaepDigestAlgorithm::Sha256) diff --git a/tools/xmlsec1/src/commands.rs b/tools/xmlsec1/src/commands.rs index 2a3a0467..e9e4a8b6 100644 --- a/tools/xmlsec1/src/commands.rs +++ b/tools/xmlsec1/src/commands.rs @@ -35,8 +35,8 @@ use xml_sec::{ xmlenc::{ DataEncryptionAlgorithm, DecryptContext, DecryptedContent, DecryptionKeyResolver, EncryptedDataBuilder, EncryptedDataType, EncryptedKey, EncryptionMethod, - EncryptionRecipient, KeyCandidateBudget, KeyTransportAlgorithm, OaepDigestAlgorithm, - PrivateKeyDecryptor, RsaOaepParameters, XmlEncError, + EncryptionRecipient, KekDecryptor, KeyCandidateBudget, KeyTransportAlgorithm, + KeyWrapAlgorithm, OaepDigestAlgorithm, PrivateKeyDecryptor, RsaOaepParameters, XmlEncError, parse_encrypted_data_template_node_with_policy_and_backend, validate_rsa_recipient_key, }, }; @@ -118,6 +118,8 @@ const VERIFY_OPTIONS: &[&str] = &[ "enable-asn1-signatures-hack", ]; const ENCRYPT_OPTIONS: &[&str] = &[ + #[cfg(feature = "legacy-algorithms")] + "des-key", "xml-backend", "print-debug", "print-xml-debug", @@ -138,6 +140,8 @@ const ENCRYPT_OPTIONS: &[&str] = &[ "add-id-attr", ]; const DECRYPT_OPTIONS: &[&str] = &[ + #[cfg(feature = "legacy-algorithms")] + "des-key", "xml-backend", "print-debug", "print-xml-debug", @@ -924,17 +928,7 @@ fn xmlsec_compatibility_signing_policy(invocation: &Invocation) -> SigningPolicy // including legacy SHA-1, without weakening the core library defaults. let mut policy = SigningPolicy { signature_algorithms: Some(HashSet::from(SignatureAlgorithm::ALL)), - digest_algorithms: Some(HashSet::from([ - DigestAlgorithm::Sha1, - DigestAlgorithm::Sha224, - DigestAlgorithm::Sha256, - DigestAlgorithm::Sha384, - DigestAlgorithm::Sha512, - DigestAlgorithm::Sha3_224, - DigestAlgorithm::Sha3_256, - DigestAlgorithm::Sha3_384, - DigestAlgorithm::Sha3_512, - ])), + digest_algorithms: Some(HashSet::from(DigestAlgorithm::ALL)), manifest_processing: if invocation.flag("ignore-manifests") { ManifestProcessing::Ignore } else { @@ -1405,6 +1399,7 @@ fn xmlsec_compatibility_verification_policy(invocation: &Invocation) -> Verifica // boundary: both CLI signing and verification use the donor interpretation, // while the core library retains the XMLDSig binding by default. let mut policy = VerificationPolicy { + digest_algorithms: Some(HashSet::from(DigestAlgorithm::ALL)), // The compatibility executable explicitly permits every compiled XML // signature method, including experimental PQ methods. Library defaults // remain restrictive; provider capability still gates execution. @@ -2295,7 +2290,7 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman "encrypt requires exactly one of --binary-data or --xml-data".into(), )); } - let policy = EncryptionPolicy::default(); + let policy = xmlsec_compatibility_encryption_policy(); let maximum_document_bytes = policy.resources.max_xml_document_bytes; let maximum_plaintext_bytes = policy.resources.max_encryption_plaintext_bytes; let template = read_input(invocation, policy.resources.max_xml_document_bytes)?; @@ -2321,7 +2316,9 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman }) .policy(policy.clone()) .xml_backend(xml_backend); - let aes_keys = invocation.values("aes-key").collect::>(); + let aes_keys = invocation + .ordered_values(&["aes-key", "des-key"]) + .collect::>(); let public_keys = invocation .ordered_values(&[ "pubkey-pem", @@ -2345,7 +2342,20 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman "encrypt cannot combine explicit AES and RSA recipient keys".into(), )); } - if !aes_keys.is_empty() { + if metadata + .recipients + .iter() + .any(|recipient| recipient.wrap.is_some()) + { + builder = configure_wrapping_recipients( + builder, + &metadata.recipients, + invocation, + &aes_keys, + &policy, + xml_backend, + )?; + } else if !aes_keys.is_empty() { if metadata.has_encrypted_key_recipient { return Err(CommandError::Usage( "direct AES key cannot satisfy an EncryptedKey recipient in the template".into(), @@ -2366,7 +2376,7 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman &requested_names, invocation.flag("lax-key-search"), true, - "AES key", + "symmetric key", )?; KeyCandidateBudget::with_limit(policy.resources.max_key_candidates) .consume(candidates.len()) @@ -2376,6 +2386,12 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman let mut selected = None; let mut last_error = None; for (option, ()) in candidates { + if !symmetric_kind_accepts(symmetric_option_kind(&option.name), algorithm) { + last_error = Some(CommandError::Usage( + "symmetric key type does not match the content encryption algorithm".into(), + )); + continue; + } match load_symmetric_with_budget( option.value.as_deref().unwrap_or_default(), Some(algorithm.key_len()), @@ -2389,7 +2405,8 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman } } let (option, key) = selected.ok_or_else(|| { - last_error.unwrap_or_else(|| CommandError::Usage("no compatible AES key input".into())) + last_error + .unwrap_or_else(|| CommandError::Usage("no compatible symmetric key input".into())) })?; builder = builder.direct_key(key); if let Some(name) = option.parameter.as_deref() { @@ -2406,7 +2423,7 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman .collect::>(); let candidates = select_store_candidates( store.symmetric_keys().iter().filter(|entry| { - entry.kind == SymmetricKeyKind::Aes + symmetric_kind_accepts(entry.kind, algorithm) && entry.usages.allows(key_manager::KeyUsage::Encrypt) }), &requested_names, @@ -2420,7 +2437,9 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman 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()))?; + .ok_or_else(|| { + CommandError::Usage("no compatible symmetric 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); @@ -2431,6 +2450,8 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman let template_recipients = if metadata.recipients.is_empty() { vec![EncryptionTemplateRecipient { key_name: None, + transport: None, + wrap: None, oaep_parameters: None, }] } else { @@ -2556,9 +2577,9 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman 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 + && available_public_keys_by_name .get(entry.name.as_str()) - .is_some_and(|available| available.reservations == 0) + .is_none_or(|available| available.reservations != 0) { continue; } @@ -2613,7 +2634,8 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman available_public_keys_by_name.remove(entry.name.as_str()); } let mut configured = - EncryptionRecipient::rsa_oaep(candidate.public_key).key_name(&entry.name); + configured_template_recipient(candidate.public_key, recipient.transport) + .key_name(&entry.name); if let Some(parameters) = recipient.oaep_parameters { configured = configured.oaep_parameters(parameters); } @@ -2623,6 +2645,8 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman let template_recipients = if metadata.recipients.is_empty() { vec![EncryptionTemplateRecipient { key_name: None, + transport: None, + wrap: None, oaep_parameters: None, }] } else { @@ -2714,10 +2738,15 @@ fn encrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman let key_name = template_recipient .key_name .or_else(|| selected_option.parameter.clone()); - selected_recipients.push((public_key, template_recipient.oaep_parameters, key_name)); + selected_recipients.push(( + public_key, + template_recipient.transport, + template_recipient.oaep_parameters, + key_name, + )); } - for (public_key, parameters, key_name) in selected_recipients { - let mut recipient = EncryptionRecipient::rsa_oaep(public_key); + for (public_key, transport, parameters, key_name) in selected_recipients { + let mut recipient = configured_template_recipient(public_key, transport); if let Some(parameters) = parameters { recipient = recipient.oaep_parameters(parameters); } @@ -3154,6 +3183,10 @@ fn template_oaep_parameters( ) -> Result, CommandError> { let transport = KeyTransportAlgorithm::from_uri(&method.algorithm) .map_err(|error| CommandError::Encryption(error.to_string()))?; + #[cfg(feature = "legacy-algorithms")] + if transport == KeyTransportAlgorithm::RsaPkcs1v15 { + return Ok(None); + } let digest = oaep_digest_from_uri( method .oaep_digest @@ -3651,7 +3684,7 @@ fn decrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman let xml_backend = selected_xml_backend(invocation)?; validate_supported_selectors(invocation, &["node-id", "id-attr", "add-id-attr"])?; let password = invocation.password_bytes(); - let policy = DecryptionPolicy::default(); + let policy = xmlsec_compatibility_decryption_policy(); let xml = read_input(invocation, policy.resources.max_xml_document_bytes)?; let encrypted_data_id = option_text(invocation, "node-id")?; let id_attributes = id_attribute_registrations(invocation)?; @@ -3660,7 +3693,33 @@ fn decrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman let standalone = encrypted_data == document.root_element(); let content_key_name = encrypted_data_key_name(encrypted_data)?; let recipient_key_names = encrypted_key_recipient_names(encrypted_data)?; - let aes_keys = invocation.values("aes-key").collect::>(); + let has_wrap_recipients = encrypted_data + .children() + .filter(|node| node.has_tag_name((XMLDSIG_NS, "KeyInfo"))) + .flat_map(|node| node.children()) + .filter(|node| node.has_tag_name((XMLENC_NS, "EncryptedKey"))) + .any(|node| { + node.children() + .filter(|child| child.has_tag_name((XMLENC_NS, "EncryptionMethod"))) + .any(|method| { + method + .attribute("Algorithm") + .is_some_and(|uri| KeyWrapAlgorithm::from_uri(uri).is_ok()) + }) + }); + let requested_symmetric_names = content_key_name + .iter() + .chain( + recipient_key_names + .iter() + .flatten() + .filter(|_| has_wrap_recipients), + ) + .cloned() + .collect::>(); + let aes_keys = invocation + .ordered_values(&["aes-key", "des-key"]) + .collect::>(); let private_keys = invocation .ordered_values(&[ "privkey-pem", @@ -3687,13 +3746,12 @@ fn decrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman .copied() .map(|option| (option, ())) .collect::>(); - let requested_names = content_key_name.iter().cloned().collect::>(); let candidates = named_candidate_search( &candidates, - &requested_names, + &requested_symmetric_names, invocation.flag("lax-key-search"), true, - "AES key", + "symmetric key", )?; KeyCandidateBudget::with_limit(policy.resources.max_key_candidates) .consume(candidates.len()) @@ -3703,17 +3761,27 @@ 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(std::borrow::Cow::Owned(key)), + Ok(key) => keys.push(SymmetricCandidate { + kind: symmetric_option_kind(&option.name), + bytes: Cow::Owned(key), + name: option.parameter.as_deref().map(Cow::Borrowed), + }), Err(error) if lax_key_search => last_error = Some(CommandError::from(error)), Err(error) => return Err(error.into()), } } if keys.is_empty() { - return Err(last_error - .unwrap_or_else(|| CommandError::Usage("no compatible AES key input".into()))); + return Err(last_error.unwrap_or_else(|| { + CommandError::Usage("no compatible symmetric key input".into()) + })); } decrypt_input( - &CandidateSymmetricKeyDecryptor { keys }, + &CandidateSymmetricKeyDecryptor { + keys, + lax_key_search, + content_key_name: content_key_name.as_deref(), + wrapping_only: has_wrap_recipients && content_key_name.is_none(), + }, &xml, encrypted_data_id, standalone, @@ -3730,7 +3798,7 @@ fn decrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman 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.kind.is_encryption_key() && entry.usages.allows(key_manager::KeyUsage::Decrypt) }) { @@ -3741,13 +3809,12 @@ fn decrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman "--keys-file does not supply RSA recipient private keys for decrypt: RSAKeyValue imports are public-only; use --privkey-pem, --privkey-der, or --pkcs12".into(), )); } - let requested_names = content_key_name.iter().cloned().collect::>(); let selected = select_store_candidates( store.symmetric_keys().iter().filter(|entry| { - entry.kind == SymmetricKeyKind::Aes + entry.kind.is_encryption_key() && entry.usages.allows(key_manager::KeyUsage::Decrypt) }), - &requested_names, + &requested_symmetric_names, invocation.flag("lax-key-search"), policy.resources.max_key_candidates, |entry| &entry.name, @@ -3758,8 +3825,15 @@ fn decrypt(invocation: &Invocation, stdout: &mut dyn Write) -> Result<(), Comman let resolver = CandidateSymmetricKeyDecryptor { keys: selected .into_iter() - .map(|entry| std::borrow::Cow::Borrowed(entry.bytes.as_slice())) + .map(|entry| SymmetricCandidate { + kind: entry.kind, + bytes: Cow::Borrowed(entry.bytes.as_slice()), + name: Some(Cow::Borrowed(entry.name.as_str())), + }) .collect(), + lax_key_search: invocation.flag("lax-key-search"), + content_key_name: content_key_name.as_deref(), + wrapping_only: has_wrap_recipients && content_key_name.is_none(), }; decrypt_input( &resolver, @@ -4011,35 +4085,153 @@ struct RecipientPrivateKey { key_name: Option, } +struct SymmetricCandidate<'a> { + kind: SymmetricKeyKind, + bytes: Cow<'a, [u8]>, + name: Option>, +} + struct CandidateSymmetricKeyDecryptor<'a> { - keys: Vec>, + keys: Vec>, + lax_key_search: bool, + content_key_name: Option<&'a str>, + wrapping_only: bool, +} + +impl CandidateSymmetricKeyDecryptor<'_> { + fn direct_candidate( + &self, + kind: SymmetricKeyKind, + name: Option<&str>, + algorithm: DataEncryptionAlgorithm, + ) -> bool { + !self.wrapping_only + && symmetric_kind_accepts(kind, algorithm) + && (self.lax_key_search + || name.is_none() + || self.content_key_name.is_none() + || name == self.content_key_name) + } +} + +fn symmetric_option_kind(name: &str) -> key_manager::SymmetricKeyKind { + if name == "des-key" { + key_manager::SymmetricKeyKind::Des + } else { + key_manager::SymmetricKeyKind::Aes + } +} + +fn symmetric_kind_accepts( + kind: key_manager::SymmetricKeyKind, + algorithm: DataEncryptionAlgorithm, +) -> bool { + kind == algorithm.key_kind() } impl DecryptionKeyResolver for CandidateSymmetricKeyDecryptor<'_> { fn resolve_key( &self, _provider: &dyn CryptoProvider, - _algorithm: DataEncryptionAlgorithm, + algorithm: DataEncryptionAlgorithm, _encrypted_key: Option<&EncryptedKey>, ) -> Result, XmlEncError> { self.keys - .first() - .map(|key| key.as_ref().to_vec()) + .iter() + .find(|key| self.direct_candidate(key.kind, key.name.as_deref(), algorithm)) + .map(|key| key.bytes.as_ref().to_vec()) .ok_or(XmlEncError::KeyNotFound) } fn resolve_key_candidates( &self, - _provider: &dyn CryptoProvider, - _algorithm: DataEncryptionAlgorithm, + provider: &dyn CryptoProvider, + algorithm: DataEncryptionAlgorithm, encrypted_key: Option<&EncryptedKey>, budget: &mut KeyCandidateBudget, ) -> Result>, XmlEncError> { - if encrypted_key.is_none() { - budget.consume(self.keys.len())?; - Ok(self.keys.iter().map(|key| key.as_ref().to_vec()).collect()) + if encrypted_key.is_some() { + return Err(XmlEncError::KeyNotFound); + } + self.resolve_key_candidates_with_policy( + provider, + algorithm, + None, + &DecryptionPolicy::default(), + budget, + ) + } + + fn resolve_key_candidates_with_policy( + &self, + provider: &dyn CryptoProvider, + algorithm: DataEncryptionAlgorithm, + encrypted_key: Option<&EncryptedKey>, + policy: &DecryptionPolicy, + budget: &mut KeyCandidateBudget, + ) -> Result>, XmlEncError> { + self.resolve_content_keys_with_policy(provider, algorithm, encrypted_key, policy, budget)? + .into_iter() + .map(|key| key.into_key().map_err(XmlEncError::from)) + .collect() + } + + fn resolve_content_keys_with_policy( + &self, + provider: &dyn CryptoProvider, + algorithm: DataEncryptionAlgorithm, + encrypted_key: Option<&EncryptedKey>, + policy: &DecryptionPolicy, + budget: &mut KeyCandidateBudget, + ) -> Result, XmlEncError> { + policy.validate()?; + let Some(recipient) = encrypted_key else { + let mut keys = Vec::new(); + for key in &self.keys { + if !self.direct_candidate(key.kind, key.name.as_deref(), algorithm) { + continue; + } + budget.consume(1)?; + keys.push(xml_sec::provider::RecoveredContentKey::confirmed( + key.bytes.to_vec(), + )); + } + return if keys.is_empty() { + Err(XmlEncError::KeyNotFound) + } else { + Ok(keys) + }; + }; + let wrap = KeyWrapAlgorithm::from_uri(&recipient.encryption_method.algorithm)?; + let mut keys = Vec::new(); + let mut last_error = XmlEncError::KeyNotFound; + for key in &self.keys { + if key.kind != wrap.key_kind() { + continue; + } + if !self.lax_key_search + && recipient.key_name.as_deref() != key.name.as_deref() + && !(recipient.key_name.is_none() && self.keys.len() == 1) + { + continue; + } + match KekDecryptor::borrowed_with_kind(&key.bytes, key.kind) + .resolve_content_keys_with_policy( + provider, + algorithm, + Some(recipient), + policy, + budget, + ) { + Ok(mut recovered) => keys.append(&mut recovered), + Err(error @ XmlEncError::Policy(_)) => return Err(error), + Err(error) => last_error = error, + } + } + if keys.is_empty() { + Err(last_error) } else { - Err(XmlEncError::KeyNotFound) + Ok(keys) } } } @@ -4098,6 +4290,20 @@ impl DecryptionKeyResolver for NamedRecipientDecryptor { policy: &DecryptionPolicy, budget: &mut KeyCandidateBudget, ) -> Result>, XmlEncError> { + self.resolve_content_keys_with_policy(provider, algorithm, encrypted_key, policy, budget)? + .into_iter() + .map(|key| key.into_key().map_err(XmlEncError::Provider)) + .collect() + } + + fn resolve_content_keys_with_policy( + &self, + provider: &dyn CryptoProvider, + algorithm: DataEncryptionAlgorithm, + encrypted_key: Option<&EncryptedKey>, + policy: &DecryptionPolicy, + budget: &mut KeyCandidateBudget, + ) -> Result, XmlEncError> { policy.validate()?; let Some(encrypted_key) = encrypted_key else { return Err(XmlEncError::KeyNotFound); @@ -4181,6 +4387,185 @@ struct EncryptionTemplateMetadata { recipients: Vec, } +fn xmlsec_compatibility_encryption_policy() -> EncryptionPolicy { + // The compatibility executable is an explicit profile boundary, as for + // XMLDSig. Library defaults remain restrictive, and provider capability + // still gates every requested primitive independently of this allowlist. + EncryptionPolicy { + #[cfg(feature = "legacy-algorithms")] + data_algorithms: Some( + [ + DataEncryptionAlgorithm::TripleDesCbc, + DataEncryptionAlgorithm::Aes192Cbc, + DataEncryptionAlgorithm::Aes192Gcm, + DataEncryptionAlgorithm::Aes128Cbc, + DataEncryptionAlgorithm::Aes256Cbc, + DataEncryptionAlgorithm::Aes128Gcm, + DataEncryptionAlgorithm::Aes256Gcm, + ] + .into(), + ), + #[cfg(feature = "legacy-algorithms")] + key_transport_algorithms: Some( + [ + KeyTransportAlgorithm::RsaPkcs1v15, + KeyTransportAlgorithm::RsaOaepMgf1p, + KeyTransportAlgorithm::RsaOaep11, + ] + .into(), + ), + #[cfg(feature = "legacy-algorithms")] + key_wrap_algorithms: Some( + [ + xml_sec::xmlenc::KeyWrapAlgorithm::TripleDes, + xml_sec::xmlenc::KeyWrapAlgorithm::AesKw192, + xml_sec::xmlenc::KeyWrapAlgorithm::AesKw128, + xml_sec::xmlenc::KeyWrapAlgorithm::AesKw256, + ] + .into(), + ), + ..EncryptionPolicy::default() + } +} + +fn configure_wrapping_recipients( + mut builder: EncryptedDataBuilder, + recipients: &[EncryptionTemplateRecipient], + invocation: &Invocation, + explicit: &[&crate::OptionValue], + policy: &EncryptionPolicy, + backend: XmlBackend, +) -> Result { + if recipients.iter().any(|recipient| recipient.wrap.is_none()) { + return Err(CommandError::Usage( + "encrypt cannot mix symmetric KEK and RSA recipients in one invocation".into(), + )); + } + let mut material = + ExternalMaterialBudget::new(policy.resources.max_external_resource_total_bytes); + let store = if invocation.values("keys-file").next().is_some() { + Some(load_xml_key_stores( + invocation, + policy, + backend, + &mut material, + )?) + } else { + None + }; + let mut budget = KeyCandidateBudget::with_limit(policy.resources.max_key_candidates); + let mut decoded = HashMap::>>::new(); + for recipient in recipients { + let wrap = recipient.wrap.expect("all recipient methods checked above"); + let requested = recipient.key_name.iter().cloned().collect::>(); + let (name, key) = if let Some(store) = &store { + let candidates = select_store_candidates( + store.symmetric_keys().iter().filter(|entry| { + entry.kind == wrap.key_kind() + && entry.usages.allows(key_manager::KeyUsage::Encrypt) + }), + &requested, + invocation.flag("lax-key-search"), + policy.resources.max_key_candidates, + |entry| &entry.name, + )?; + budget + .consume(candidates.len()) + .map_err(|error| CommandError::Encryption(error.to_string()))?; + let selected = candidates + .into_iter() + .find(|entry| entry.bytes.len() == wrap.key_len()) + .ok_or_else(|| { + CommandError::Usage("no compatible wrapping key in --keys-file".into()) + })?; + (Some(selected.name.clone()), selected.bytes.to_vec()) + } else { + let candidates = explicit + .iter() + .copied() + .enumerate() + .filter(|(_, option)| symmetric_option_kind(&option.name) == wrap.key_kind()) + .map(|(index, option)| (option, index)) + .collect::>(); + let candidates = named_candidate_search( + &candidates, + &requested, + invocation.flag("lax-key-search"), + true, + "wrapping key", + )?; + budget + .consume(candidates.len()) + .map_err(|error| CommandError::Encryption(error.to_string()))?; + let mut selected = None; + let mut last_error = None; + for (option, index) in candidates { + if let Some(key) = decoded.get(&index) { + if key.len() == wrap.key_len() { + selected = Some((option.parameter.clone(), key.to_vec())); + break; + } + last_error = Some(CommandError::Usage( + "wrapping key length does not match the template method".into(), + )); + continue; + } + match load_symmetric_with_budget( + option.value.as_deref().unwrap_or_default(), + Some(wrap.key_len()), + &mut material, + ) { + Ok(key) => { + // Recipient builders retain their own key bytes. Cache + // only batched inputs to avoid repeated filesystem I/O; + // single-recipient operations have no cache allocation. + if recipients.len() > 1 { + decoded.insert(index, zeroize::Zeroizing::new(key.clone())); + } + selected = Some((option.parameter.clone(), key)); + break; + } + Err(error) => last_error = Some(error), + } + } + selected.ok_or_else(|| { + last_error.unwrap_or_else(|| { + CommandError::Usage("no compatible wrapping key input".into()) + }) + })? + }; + let mut configured = EncryptionRecipient::aes_key_wrap(key, wrap); + if let Some(name) = name { + configured = configured.key_name(name); + } + builder = builder.add_recipient(configured); + } + Ok(builder) +} + +fn xmlsec_compatibility_decryption_policy() -> DecryptionPolicy { + let compatibility = xmlsec_compatibility_encryption_policy(); + DecryptionPolicy { + data_algorithms: compatibility.data_algorithms, + key_transport_algorithms: compatibility.key_transport_algorithms, + key_wrap_algorithms: compatibility.key_wrap_algorithms, + ..DecryptionPolicy::default() + } +} + +fn configured_template_recipient( + public_key: RsaPublicKey, + transport: Option, +) -> EncryptionRecipient { + #[cfg(feature = "legacy-algorithms")] + if transport == Some(KeyTransportAlgorithm::RsaPkcs1v15) { + return EncryptionRecipient::rsa_pkcs1v15(public_key); + } + #[cfg(not(feature = "legacy-algorithms"))] + let _ = transport; + EncryptionRecipient::rsa_oaep(public_key) +} + #[derive(Clone, Copy, Eq, PartialEq)] enum EncryptionTemplatePlacement { Standalone, @@ -4189,6 +4574,8 @@ enum EncryptionTemplatePlacement { struct EncryptionTemplateRecipient { key_name: Option, + transport: Option, + wrap: Option, oaep_parameters: Option, } @@ -4225,9 +4612,23 @@ fn encryption_template( .encrypted_keys .into_iter() .map(|encrypted_key| { + let wrap = KeyWrapAlgorithm::from_uri(&encrypted_key.encryption_method.algorithm).ok(); Ok(EncryptionTemplateRecipient { key_name: encrypted_key.key_name, - oaep_parameters: template_oaep_parameters(&encrypted_key.encryption_method)?, + transport: if wrap.is_some() { + None + } else { + Some( + KeyTransportAlgorithm::from_uri(&encrypted_key.encryption_method.algorithm) + .map_err(|error| CommandError::Encryption(error.to_string()))?, + ) + }, + wrap, + oaep_parameters: if wrap.is_some() { + None + } else { + template_oaep_parameters(&encrypted_key.encryption_method)? + }, }) }) .collect::, CommandError>>()?; @@ -4920,6 +5321,16 @@ mod tests { assert_eq!( policy.signature_algorithms, Some(HashSet::from([ + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::RsaMd5, + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::RsaRipemd160, + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::HmacMd5, + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::HmacRipemd160, + #[cfg(feature = "legacy-algorithms")] + SignatureAlgorithm::EcdsaRipemd160, SignatureAlgorithm::DsaSha1, SignatureAlgorithm::DsaSha256, SignatureAlgorithm::HmacSha1, @@ -4960,6 +5371,10 @@ mod tests { assert_eq!( policy.digest_algorithms, Some(HashSet::from([ + #[cfg(feature = "legacy-algorithms")] + DigestAlgorithm::Md5, + #[cfg(feature = "legacy-algorithms")] + DigestAlgorithm::Ripemd160, DigestAlgorithm::Sha1, DigestAlgorithm::Sha224, DigestAlgorithm::Sha256, @@ -4997,6 +5412,140 @@ mod tests { ); } + #[cfg(feature = "legacy-algorithms")] + #[test] + fn direct_candidates_do_not_cross_aes_and_triple_des_families() { + // Equal 24-byte lengths must not turn an AES key into a TDEA key. + let resolver = CandidateSymmetricKeyDecryptor { + keys: vec![SymmetricCandidate { + kind: key_manager::SymmetricKeyKind::Aes, + bytes: Cow::Borrowed(&[1; 24]), + name: None, + }], + lax_key_search: false, + content_key_name: None, + wrapping_only: false, + }; + assert!(matches!( + resolver.resolve_key( + &xml_sec::provider::RustCryptoProvider, + DataEncryptionAlgorithm::TripleDesCbc, + None + ), + Err(XmlEncError::KeyNotFound) + )); + assert!( + resolver + .resolve_key( + &xml_sec::provider::RustCryptoProvider, + DataEncryptionAlgorithm::Aes192Cbc, + None + ) + .is_ok() + ); + } + + #[cfg(feature = "legacy-algorithms")] + #[test] + fn cli_optional_content_round_trips_and_rejects_wrong_key_type() { + // Exercise the actual command boundary, including named key selection + // and explicit compatibility policy; equal AES/TDEA widths cannot alias. + let directory = tempfile::tempdir().expect("temporary CLI files"); + let template = directory.path().join("template.xml"); + let input = directory.path().join("input.bin"); + let key = directory.path().join("key.bin"); + let encrypted = directory.path().join("encrypted.xml"); + fs::write(&input, b"legacy CLI plaintext").expect("plaintext file"); + fs::write(&key, [0x31; 24]).expect("key file"); + for algorithm in [ + DataEncryptionAlgorithm::TripleDesCbc, + DataEncryptionAlgorithm::Aes192Cbc, + DataEncryptionAlgorithm::Aes192Gcm, + ] { + fs::write(&template, format!("", algorithm.uri())).expect("template file"); + let option = if algorithm == DataEncryptionAlgorithm::TripleDesCbc { + "--deskey" + } else { + "--aeskey" + }; + let args = [ + "xmlsec1", + "encrypt", + option, + key.to_str().unwrap(), + "--binary-data", + input.to_str().unwrap(), + template.to_str().unwrap(), + ]; + let mut output = Vec::new(); + execute(invocation(&args), &mut output, &mut Vec::new()).expect("CLI encryption"); + fs::write(&encrypted, output).expect("encrypted file"); + let args = [ + "xmlsec1", + "decrypt", + option, + key.to_str().unwrap(), + encrypted.to_str().unwrap(), + ]; + let mut plaintext = Vec::new(); + execute(invocation(&args), &mut plaintext, &mut Vec::new()).expect("CLI decryption"); + assert_eq!(plaintext, b"legacy CLI plaintext"); + let wrong_option = if option == "--deskey" { + "--aeskey" + } else { + "--deskey" + }; + let args = [ + "xmlsec1", + "decrypt", + wrong_option, + key.to_str().unwrap(), + encrypted.to_str().unwrap(), + ]; + assert!(execute(invocation(&args), &mut Vec::new(), &mut Vec::new()).is_err()); + } + } + + #[cfg(feature = "legacy-algorithms")] + #[test] + fn cli_rsa15_template_selects_parameterless_transport() { + // Test command wiring, not just the library builder: a template's + // RSA-1.5 method must survive staging without acquiring OAEP children. + let directory = tempfile::tempdir().expect("CLI files"); + let template = directory.path().join("template.xml"); + let input = directory.path().join("input.bin"); + let encrypted = directory.path().join("encrypted.xml"); + fs::write(&input, b"RSA15 CLI plaintext").expect("plaintext"); + fs::write(&template, "").expect("template"); + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let public = root.join("tests/fixtures/keys/rsa/rsa-2048-pubkey.pem"); + let private = root.join("tests/fixtures/keys/rsa/rsa-2048-key.pem"); + let args = [ + "xmlsec1", + "encrypt", + "--pubkey-pem", + public.to_str().unwrap(), + "--binary-data", + input.to_str().unwrap(), + template.to_str().unwrap(), + ]; + let mut output = Vec::new(); + execute(invocation(&args), &mut output, &mut Vec::new()).expect("CLI RSA-1.5 encrypt"); + assert!(!String::from_utf8_lossy(&output).contains("OAEPparams")); + assert!(!String::from_utf8_lossy(&output).contains("DigestMethod")); + fs::write(&encrypted, output).expect("ciphertext"); + let args = [ + "xmlsec1", + "decrypt", + "--privkey-pem", + private.to_str().unwrap(), + encrypted.to_str().unwrap(), + ]; + let mut output = Vec::new(); + execute(invocation(&args), &mut output, &mut Vec::new()).expect("CLI RSA-1.5 decrypt"); + assert_eq!(output, b"RSA15 CLI plaintext"); + } + #[test] fn store_selection_bounds_inspected_candidates() { // A name filter cannot make scanning an oversized candidate pool free. diff --git a/tools/xmlsec1/src/key_material.rs b/tools/xmlsec1/src/key_material.rs index 0db64ee3..da319079 100644 --- a/tools/xmlsec1/src/key_material.rs +++ b/tools/xmlsec1/src/key_material.rs @@ -341,25 +341,13 @@ pub fn decode_signing_key( password: Option<&[u8]>, ) -> Result, KeyMaterialError> { match algorithm { - SignatureAlgorithm::RsaSha1 - | SignatureAlgorithm::RsaSha224 - | SignatureAlgorithm::RsaSha256 - | SignatureAlgorithm::RsaSha384 - | SignatureAlgorithm::RsaSha512 => decode_rsa_signing_key(path, bytes, format, password), + method if method.is_rsa() => decode_rsa_signing_key(path, bytes, format, password), SignatureAlgorithm::DsaSha1 | SignatureAlgorithm::DsaSha256 => { // XMLDSig defines DSA signature methods only for SHA-1 and // SHA-256. SHA-224 URIs exist for other key families, not DSA. decode_dsa_signing_key(path, bytes, format, password) } - SignatureAlgorithm::EcdsaSha1 - | SignatureAlgorithm::EcdsaSha224 - | SignatureAlgorithm::EcdsaSha256 - | SignatureAlgorithm::EcdsaSha384 - | SignatureAlgorithm::EcdsaSha512 - | SignatureAlgorithm::EcdsaSha3_224 - | SignatureAlgorithm::EcdsaSha3_256 - | SignatureAlgorithm::EcdsaSha3_384 - | SignatureAlgorithm::EcdsaSha3_512 => { + method if method.ecdsa_digest().is_some() => { decode_ecdsa_signing_key(path, bytes, format, password) } SignatureAlgorithm::HmacSha1 diff --git a/tools/xmlsec1/tests/process_contract.rs b/tools/xmlsec1/tests/process_contract.rs index 19b20650..862d4413 100644 --- a/tools/xmlsec1/tests/process_contract.rs +++ b/tools/xmlsec1/tests/process_contract.rs @@ -47,6 +47,109 @@ fn project_root() -> &'static Path { Path::new(env!("CARGO_MANIFEST_DIR")) } +#[cfg(feature = "legacy-algorithms")] +#[test] +fn symmetric_recipient_wraps_are_executable_through_cli() { + // Advertised transforms must be reachable through real commands, including + // a KEK family different from the content cipher and named recipient keys. + let temp = tempfile::tempdir().unwrap(); + let plaintext = temp.path().join("plain.bin"); + let key = temp.path().join("kek.bin"); + fs::write(&plaintext, b"wrapped content").unwrap(); + fs::write(&key, [0x31; 24]).unwrap(); + for (wrap, option) in [ + ("kw-aes192", "--aes-key:kek"), + ("kw-tripledes", "--des-key:kek"), + ] { + let template = temp.path().join("template.xml"); + let encrypted = temp.path().join("encrypted.xml"); + let decrypted = temp.path().join("decrypted.bin"); + fs::write(&template, format!( + "kek" + )).unwrap(); + let result = Command::new(binary()) + .args(["encrypt", option]) + .arg(&key) + .arg("--binary-data") + .arg(&plaintext) + .arg("--output") + .arg(&encrypted) + .arg(&template) + .output() + .unwrap(); + assert!( + result.status.success(), + "{wrap}: {}", + String::from_utf8_lossy(&result.stderr) + ); + let result = Command::new(binary()) + .args(["decrypt", option]) + .arg(&key) + .arg("--output") + .arg(&decrypted) + .arg(&encrypted) + .output() + .unwrap(); + assert!( + result.status.success(), + "{wrap}: {}", + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!(fs::read(&decrypted).unwrap(), b"wrapped content"); + let wrong_option = if option == "--aes-key:kek" { + "--des-key:kek" + } else { + "--aes-key:kek" + }; + let rejected = Command::new(binary()) + .args(["decrypt", wrong_option]) + .arg(&key) + .arg(&encrypted) + .output() + .unwrap(); + assert!( + !rejected.status.success(), + "{wrap} must reject the wrong key family" + ); + // Stored keys must follow the same KEK path as explicitly named files. + let store = temp.path().join("keys.xml"); + let value_name = if option == "--aes-key:kek" { + "AESKeyValue" + } else { + "DESKeyValue" + }; + let encoded = base64::engine::general_purpose::STANDARD.encode([0x31; 24]); + fs::write(&store, format!("kek<{value_name}>{encoded}")).unwrap(); + let result = Command::new(binary()) + .args(["encrypt", "--keys-file"]) + .arg(&store) + .arg("--binary-data") + .arg(&plaintext) + .arg("--output") + .arg(&encrypted) + .arg(&template) + .output() + .unwrap(); + assert!( + result.status.success(), + "{wrap} store encrypt: {}", + String::from_utf8_lossy(&result.stderr) + ); + let result = Command::new(binary()) + .args(["decrypt", "--keys-file"]) + .arg(&store) + .arg(&encrypted) + .output() + .unwrap(); + assert!( + result.status.success(), + "{wrap} store decrypt: {}", + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!(result.stdout, b"wrapped content"); + } +} + #[cfg(feature = "aws-lc-fips")] #[test] fn aws_lc_selection_signs_and_verifies_through_cli() { @@ -5589,7 +5692,9 @@ fn named_aes_key_ring_selects_one_key_for_encryption_and_decryption() { .output() .unwrap(); assert!(!ambiguous.status.success()); - assert!(String::from_utf8_lossy(&ambiguous.stderr).contains("multiple AES key")); + // The shared selector identifies symmetric keys, including DES, rather + // than labeling every ambiguous candidate ring as AES-only. + assert!(String::from_utf8_lossy(&ambiguous.stderr).contains("multiple symmetric key")); // Lax lookup intentionally ignores duplicate identity metadata and searches // by key kind, so the first compatible entry can complete the round trip.