diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb126071..555ea49a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,7 +145,7 @@ jobs: env: PKCS11_MODULE: /usr/lib/softhsm/libsofthsm2.so run: | - for profile in dom-xmloxide dom-roxmltree c14n-xmloxide c14n-roxmltree dsig-xmloxide dsig-roxmltree dsig-sign-xmloxide xmlenc-xmloxide xmlenc-roxmltree xslt legacy experimental fips-dsig pkcs11 no-backend; do + for profile in dom-xmloxide dom-roxmltree c14n-xmloxide c14n-roxmltree dsig-xmloxide dsig-roxmltree cms-xmloxide dsig-sign-xmloxide xmlenc-xmloxide xmlenc-roxmltree xslt legacy experimental fips-dsig pkcs11 no-backend; do echo "::group::$profile" cargo run --quiet -p xml-sec-tools -- check-features "$profile" echo '::endgroup::' @@ -281,6 +281,10 @@ jobs: cargo clippy -p xml-sec --lib --no-default-features --features xmldsig,xml-backend-roxmltree --target thumbv7em-none-eabihf -- -D warnings + - run: >- + cargo clippy -p xml-sec --lib --no-default-features + --features cms,xml-backend-xmloxide + --target thumbv7em-none-eabihf -- -D warnings - uses: taiki-e/install-action@nextest - name: Target coverage on the build without std run: | diff --git a/Cargo.toml b/Cargo.toml index 1c4fa878..35e9e474 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -416,6 +416,7 @@ std = [ "xmlparser/std", "structured-asn1?/std", "structured-pkix?/std", + "structured-cms?/std", "base64/std", "snafu?/std", "der?/std", @@ -466,6 +467,8 @@ xmldsig = [ # XML Digital Signature verification and public-key inven "dep:tinyvec", "dep:zeroize", ] +# CMS SignedData and RFC 3161 timestamp signatures through the selected provider. +cms = ["xmldsig", "dep:structured-cms"] # Signing, signing randomness and private-key import (PEM/PKCS#8, encrypted # PKCS#8, PKCS#12) on top of verification. xmldsig-sign = [ diff --git a/docs/features.md b/docs/features.md index ef15cb63..52e49b15 100644 --- a/docs/features.md +++ b/docs/features.md @@ -11,6 +11,7 @@ See [provider boundaries](crypto-providers.md) and [policy profiles](policy-prof | Defaults | `std`, `c14n`, `xmldsig`, `xmldsig-sign`, `xmlenc`, `xml-backend-xmloxide` | | `c14n` plus one XML backend | Canonicalization without signature/encryption dependencies; builds without `std` | | `xmldsig` plus one XML backend | Signature verification, policy and the public-key inventory; builds without `std` | +| `cms` plus one XML backend | Adds the CMS `SignedData` and RFC 3161 timestamp signature provider over the selected crypto provider; implies `xmldsig`, not signing; builds without `std` | | `xmldsig-sign` plus one XML backend | Adds signing, signature templates, private-key import (PEM, PKCS#8, encrypted PKCS#8, PKCS#12) and the bundled randomness source; implies `xmldsig` and `std` | | `xmlenc` plus one XML backend | Encryption and its shared signature/key-inventory graph; implies `xmldsig-sign` and `std` | | `xslt` plus one XML backend | XMLDSig XSLT support; implies `xmldsig` and `std` | @@ -41,8 +42,8 @@ helpers remain available with an XML backend even without the `c14n` marker. ### Builds without `std` -Without the `std` feature the crate is `no_std` with `alloc`: C14N and XMLDSig -verification build for targets such as `thumbv7em-none-eabihf` and +Without the `std` feature the crate is `no_std` with `alloc`: C14N, XMLDSig +verification and the `cms` provider build for targets such as `thumbv7em-none-eabihf` and `wasm32-unknown-unknown`. Host-only capabilities keep requiring `std`: signing and its randomness (`xmldsig-sign`), `xmlenc`, native providers and PKCS#11. @@ -69,8 +70,10 @@ public material, X.509 path and CRL validation and the public-key inventory. It compiles no signing pipeline, signature templates, private-key import, password-based key derivation or operating-system randomness: `pkcs12`, `pkcs5`, `scrypt`, `pbkdf2`, `cms`, `x509-cert`, `x509-parser` and `getrandom` -are absent from its dependency graph. Certificates are read only through the -shared PKIX reader and PEM through one RFC 7468 decoder. +are absent from its dependency graph, and so is the `structured-cms` processor. +Certificates are read only through the shared PKIX reader and PEM through one +RFC 7468 decoder. The `cms` feature adds `structured-cms` for timestamp and +`SignedData` checks and nothing from that signing and import list. `CryptoProvider` keeps one method set in every build, so an external provider compiles unchanged whichever features another crate enables. The bundled @@ -100,10 +103,13 @@ dependency/setup errors cannot satisfy it. The `dsig-xmloxide` and `dsig-roxmltree` profiles are verification-only: before linting they fail if any crate listed above enters the normal dependency graph, and they run the library tests and `tests/verify_only_build.rs`. Tests that -create their fixtures by signing run in the signing profiles. +create their fixtures by signing run in the signing profiles. The +`cms-xmloxide` profile applies the same graph check, admitting only +`structured-cms`, and runs `tests/cms_timestamp.rs` over RFC 3161 tokens that +`scripts/generate-cms-timestamp-fixtures.sh` obtains from OpenSSL. Other profiles are `dom-xmloxide`, `dom-roxmltree`, `c14n-xmloxide`, -`dsig-xmloxide`, `dsig-sign-xmloxide`, `xmlenc-roxmltree`, `xslt`, `legacy`, +`dsig-xmloxide`, `cms-xmloxide`, `dsig-sign-xmloxide`, `xmlenc-roxmltree`, `xslt`, `legacy`, `experimental`, `fips-dsig` and `pkcs11`. Native FIPS requirements are documented by [AWS](https://aws.github.io/aws-lc-rs/requirements/index.html). For token execution supply an explicit test module: diff --git a/scripts/generate-cms-timestamp-fixtures.sh b/scripts/generate-cms-timestamp-fixtures.sh new file mode 100644 index 00000000..093457c8 --- /dev/null +++ b/scripts/generate-cms-timestamp-fixtures.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Regenerate the RFC 3161 timestamp fixtures of tests/cms_timestamp.rs. +# +# OpenSSL acts as an independent time-stamping authority. For each key type it +# issues a self-signed TSA certificate with the critical timeStamping extended +# key usage (RFC 3161 §2.3). `openssl ts` stamps the payload with RSA and ECDSA +# keys. Its PKCS #7 signer supports neither Ed25519 nor RSA-PSS, so for those +# keys `openssl cms` signs the TSTInfo of the RSA token as id-ct-TSTInfo +# content, with the ESS signingCertificateV2 attribute (-cades). Trust in the +# TSA is outside these fixtures; they exercise CMS signature and imprint checks. +# +# Usage: scripts/generate-cms-timestamp-fixtures.sh +# CMS_FIXTURE_OUTPUT optionally selects an absolute output directory. +# Requires OpenSSL 3.4 or later for fixed certificate validity dates. +set -euo pipefail + +repository="$(cd "$(dirname "$0")/.." && pwd)" +output="${CMS_FIXTURE_OUTPUT:-$repository/tests/fixtures/cms/timestamp}" +mkdir -p "$output" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +cd "$work" + +NOT_BEFORE=20260101000000Z +NOT_AFTER=20450101000000Z +TST_INFO=1.2.840.113549.1.9.16.1.4 + +printf 'Time-stamped payload\n' >payload.txt +cp payload.txt "$output/payload.txt" +openssl ts -query -data payload.txt -sha256 -cert -no_nonce -out payload.tsq 2>/dev/null + +cat >ts.cnf <<'EOF' +[tsa] +default_tsa = tsa_config + +[tsa_config] +serial = ./serial +crypto_device = builtin +signer_digest = sha256 +default_policy = 1.3.6.1.4.1.55555.1 +digests = sha256, sha384, sha512 +accuracy = secs:1 +ordering = no +tsa_name = no +ess_cert_id_chain = no +ess_cert_id_alg = sha256 +EOF + +# authority +authority() { + local name="$1" + shift + openssl genpkey "$@" -out "$name.key" 2>/dev/null + openssl req -x509 -new -key "$name.key" -out "$name.pem" \ + -not_before "$NOT_BEFORE" -not_after "$NOT_AFTER" \ + -subj "/CN=xml-sec Test TSA $name" \ + -addext "basicConstraints=critical,CA:FALSE" \ + -addext "keyUsage=critical,digitalSignature" \ + -addext "extendedKeyUsage=critical,timeStamping" 2>/dev/null + openssl x509 -in "$name.pem" -outform DER -out "$output/$name.der" +} + +# stamp : an `openssl ts` token signed by that authority. +stamp() { + echo 01 >serial + openssl ts -reply -config ts.cnf -queryfile payload.tsq \ + -signer "$1.pem" -inkey "$1.key" -token_out -out "$output/$1.tst" 2>/dev/null +} + +# resign : the RSA token's TSTInfo signed +# by that authority. +resign() { + local name="$1" + shift + openssl cms -sign -binary -nodetach -in tstinfo.der -econtent_type "$TST_INFO" \ + -signer "$name.pem" -inkey "$name.key" -cades -nosmimecap "$@" \ + -outform DER -out "$output/$name.tst" +} + +authority rsa -algorithm RSA -pkeyopt rsa_keygen_bits:2048 +stamp rsa +authority ecdsa-p256 -algorithm EC -pkeyopt ec_paramgen_curve:P-256 +stamp ecdsa-p256 + +openssl cms -verify -noverify -inform DER -in "$output/rsa.tst" -out tstinfo.der 2>/dev/null +authority ed25519 -algorithm ED25519 +resign ed25519 -md sha512 +authority rsa-pss -algorithm RSA -pkeyopt rsa_keygen_bits:2048 +resign rsa-pss -md sha256 -keyopt rsa_padding_mode:pss -keyopt rsa_pss_saltlen:32 + +# Certificates read only for their own signature identifiers. +authority ed448 -algorithm ED448 +openssl req -x509 -new -key rsa-pss.key -outform DER -out "$output/rsa-pss-signed.der" \ + -sha256 -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:32 \ + -not_before "$NOT_BEFORE" -not_after "$NOT_AFTER" \ + -subj "/CN=xml-sec Test PSS signature" 2>/dev/null diff --git a/src/xmldsig/cms.rs b/src/xmldsig/cms.rs new file mode 100644 index 00000000..1033ef23 --- /dev/null +++ b/src/xmldsig/cms.rs @@ -0,0 +1,399 @@ +//! CMS `SignedData` and RFC 3161 signature checks through the selected provider. +//! +//! [`CmsProvider`] implements the `structured-cms` provider contract over one +//! [`CryptoProvider`] and one compiled [`VerificationPolicy`]: digests start +//! through [`SessionDigest`] under the digest policy, and signature identifiers +//! resolve through the shared PKIX mapping under the certificate-signature +//! permissions and RSA/DSA key policy. Trust in the signer remains the +//! caller's decision. +//! +//! The provider interface verifies contiguous signed bytes, so every signature +//! is processed in raw mode: the signed octets are admitted against the +//! operation's memory and verified in one call. CMS timestamp tokens sign only +//! their small `signedAttrs` set (RFC 3161 §2.4.2). + +use core::cmp::Ordering; + +use structured_asn1::digest::{Digest, Resolution}; +use structured_asn1::memory::{MemoryError, OwnedVec, Work}; +use structured_asn1::{Encoding, Reader}; +use structured_cms::Octets; +use structured_cms::provider::{Prehash, SignatureAnswer, SignatureMode, SignatureProvider}; +use structured_pkix::AlgorithmIdentifier; +use structured_pkix::algorithm::digest::{Algorithm, AsnDigest}; +use structured_pkix::path::WorkingKey; + +use super::pkix::CertificateSession; +use super::verify::SessionDigest; +use super::{DigestAlgorithm, DsigError, X509ChainError}; +use crate::policy::{KeyTrustPolicy, PolicyViolation, VerificationPolicy}; +use crate::provider::{CryptoProvider, ProviderCapability, X509SignatureAlgorithm}; + +/// RFC 8017 Appendix A.1 `rsaEncryption`, 1.2.840.113549.1.1.1. +const RSA_ENCRYPTION: &[u8] = &[0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01]; + +/// The `structured-cms` provider of one verification operation. +/// +/// One value serves one CMS processing session: its signer key checks share +/// one [`ResourcePolicy::max_x509_work`](crate::policy::ResourcePolicy) account. +pub struct CmsProvider<'p> { + provider: &'p dyn CryptoProvider, + policy: &'p VerificationPolicy, + certificates: CertificateSession, +} + +impl core::fmt::Debug for CmsProvider<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("CmsProvider") + .field("provider", &self.provider.name()) + .finish_non_exhaustive() + } +} + +impl<'p> CmsProvider<'p> { + /// Bind `provider` and `policy` and open the session's certificate account. + #[must_use] + pub fn new(provider: &'p dyn CryptoProvider, policy: &'p VerificationPolicy) -> Self { + Self { + provider, + policy, + certificates: CertificateSession::new(&policy.resources), + } + } +} + +/// A CMS provider failure, distinct from an invalid signature or digest mismatch. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum CmsProviderError { + /// The operation policy refuses the algorithm or the signer's key strength. + #[error(transparent)] + Policy(#[from] PolicyViolation), + /// The signer's key could not be processed. + #[error(transparent)] + Certificate(X509ChainError), + /// Digest execution failed in the selected provider. + #[error(transparent)] + Digest(DsigError), + /// The current execution refused work or storage. + #[error("CMS provider resource refusal: {0}")] + Resource(MemoryError), + /// A finalized prehash reached a provider that only selects raw mode. + #[error("finalized-prehash verification is not selected by this provider")] + PrehashNotSelected, +} + +impl From for CmsProviderError { + fn from(error: MemoryError) -> Self { + Self::Resource(error) + } +} + +impl From for CmsProviderError { + fn from(error: X509ChainError) -> Self { + match error { + X509ChainError::Policy(violation) + | X509ChainError::KeyPolicy { + source: violation, .. + } => Self::Policy(violation), + other => Self::Certificate(other), + } + } +} + +impl From for CmsProviderError { + fn from(error: DsigError) -> Self { + match error { + DsigError::Policy(violation) => Self::Policy(violation), + other => Self::Digest(other), + } + } +} + +/// A finished digest, held inline. +#[derive(Debug, Clone, Copy)] +pub struct DigestOutput { + bytes: [u8; 64], + len: usize, +} + +impl AsRef<[u8]> for DigestOutput { + fn as_ref(&self) -> &[u8] { + &self.bytes[..self.len] + } +} + +impl<'a> Digest<'a> for CmsProvider<'_> { + type Identity = DigestAlgorithm; + type State = SessionDigest<'a>; + type Output = DigestOutput; + type Error = CmsProviderError; + + fn width(&self, identity: &DigestAlgorithm) -> Option { + Some(identity.output_len()) + } + + fn compare( + &self, + left: &DigestAlgorithm, + right: &DigestAlgorithm, + execution: &Q, + ) -> Result { + execution.charge_work(1)?; + Ok(left.cmp(right)) + } + + fn start( + &'a self, + identity: &DigestAlgorithm, + execution: &Q, + ) -> Result>, CmsProviderError> { + execution.charge_work(1)?; + Ok(Resolution::Supported(SessionDigest::begin( + self.provider, + self.policy, + *identity, + )?)) + } + + fn update( + &self, + state: &mut SessionDigest<'a>, + bytes: &[u8], + execution: &Q, + ) -> Result<(), CmsProviderError> { + Ok(state.update(bytes, execution)?) + } + + fn finish( + &self, + state: SessionDigest<'a>, + execution: &Q, + ) -> Result { + let mut output = DigestOutput { + bytes: [0; 64], + len: state.output_len(), + }; + state.finish(&mut output.bytes[..output.len], execution)?; + Ok(output) + } + + fn matches( + &self, + _identity: &DigestAlgorithm, + output: &DigestOutput, + expected: &[u8], + execution: &Q, + ) -> Result { + execution.charge_work(expected.len() as u64)?; + Ok(super::constant_time_eq(output.as_ref(), expected)) + } +} + +impl AsnDigest<'_> for CmsProvider<'_> { + // A digest the policy refuses keeps its identity here and fails as a + // typed policy violation when it starts, rather than as unsupported. + fn identity(&self, algorithm: Algorithm) -> Option { + super::pkix::digest_algorithm(algorithm) + } +} + +/// Raw signature input admitted against the operation's memory. +pub struct RawSignature<'a> { + algorithm: X509SignatureAlgorithm, + key: WorkingKey<'a>, + signature: &'a Octets<'a>, + signed: OwnedVec, +} + +impl core::fmt::Debug for RawSignature<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("RawSignature") + .field("algorithm", &self.algorithm) + .field("signed_len", &self.signed.len()) + .finish_non_exhaustive() + } +} + +impl CmsProvider<'_> { + /// The provider algorithm `identifier` names for a signer whose + /// `digestAlgorithm` resolved to `digest`, or why there is none. + fn signature_algorithm( + identifier: AlgorithmIdentifier<'_>, + digest: DigestAlgorithm, + parent: &Reader<'_>, + ) -> Result, CmsProviderError> { + let execution = parent.execution(); + execution.charge_work(identifier.algorithm().as_bytes().len() as u64)?; + if identifier.algorithm().as_bytes() == RSA_ENCRYPTION { + // RFC 3370 §3.2: rsaEncryption identifies PKCS #1 v1.5 signatures + // whatever the digest, which digestAlgorithm names; its + // parameters MUST be NULL. + // https://www.rfc-editor.org/rfc/rfc3370#section-3.2 + return Ok(if identifier.has_null_parameter() { + Resolution::Supported(X509SignatureAlgorithm::RsaPkcs1v15(digest)) + } else { + Resolution::Malformed + }); + } + let Some(algorithm) = super::pkix::known_signature_algorithm(identifier, &execution)? + else { + return Ok(Resolution::Unsupported); + }; + // Raw verification never reads digestAlgorithm, so only this check + // binds it. RFC 5754 sets no such rule for PKCS #1 v1.5 or DSA. + let consistent = match algorithm { + // RFC 5753 §2.1.1: the hash named by an ECDSA signature algorithm + // MUST be the same as digestAlgorithm. + // https://www.rfc-editor.org/rfc/rfc5753#section-2.1.1 + // RFC 4056 §3: signedAttrs and the RSASSA-PSS hashAlgorithm MUST + // use the same hash. + // https://www.rfc-editor.org/rfc/rfc4056#section-3 + X509SignatureAlgorithm::Ecdsa(hash) + | X509SignatureAlgorithm::RsaPss { digest: hash, .. } => hash == digest, + // RFC 8419 §3.1 and §3.2: digestAlgorithm MUST be id-sha512 for + // Ed25519, and a SHAKE256 identifier for Ed448, which never + // resolves to a DigestAlgorithm. + // https://www.rfc-editor.org/rfc/rfc8419#section-3 + X509SignatureAlgorithm::Ed25519 => digest == DigestAlgorithm::Sha512, + X509SignatureAlgorithm::Ed448 => false, + _ => true, + }; + Ok(if consistent { + Resolution::Supported(algorithm) + } else { + Resolution::Malformed + }) + } +} + +impl<'a> SignatureProvider<'a> for CmsProvider<'_> { + type SignatureIdentity = X509SignatureAlgorithm; + type RawState = RawSignature<'a>; + + fn resolve_signature( + &'a self, + identifier: AlgorithmIdentifier<'a>, + digest: &DigestAlgorithm, + key: WorkingKey<'a>, + _encoding: Encoding, + parent: &Reader<'a>, + ) -> Result>, CmsProviderError> { + let algorithm = match Self::signature_algorithm(identifier, *digest, parent)? { + Resolution::Supported(algorithm) => algorithm, + Resolution::Unsupported => return Ok(Resolution::Unsupported), + Resolution::Malformed => return Ok(Resolution::Malformed), + }; + // Permission precedes capability, as for certificate signatures. + KeyTrustPolicy::check_certificate_signature_algorithm( + algorithm, + self.policy + .key_trust + .certificate_signature_algorithms + .as_ref(), + )?; + if !self + .provider + .supports(ProviderCapability::VerifyCertificate(algorithm)) + { + return Ok(Resolution::Unsupported); + } + // Key strength is checked before any signed byte is read. + super::pkix::with_effective_spki(key, &parent.execution(), |spki| { + super::x509::validate_issuer_spki_policy( + spki, + 0, + self.policy.key_trust.rsa_keys, + self.policy.key_trust.dsa_keys, + &self.certificates, + ) + })?; + Ok(Resolution::Supported(SignatureMode::Raw(algorithm))) + } + + fn compare_signature( + &self, + left: &X509SignatureAlgorithm, + right: &X509SignatureAlgorithm, + execution: &W, + ) -> Result { + execution.charge_work(1)?; + Ok(left.cmp(right)) + } + + fn start_raw( + &'a self, + identity: &X509SignatureAlgorithm, + key: WorkingKey<'a>, + signature: &'a Octets<'a>, + execution: &W, + ) -> Result, CmsProviderError> { + execution.charge_work(1)?; + Ok(RawSignature { + algorithm: *identity, + key, + signature, + signed: execution.vector().into_owned(), + }) + } + + fn update_raw( + &self, + state: &mut RawSignature<'a>, + bytes: &[u8], + execution: &W, + ) -> Result<(), CmsProviderError> { + execution.charge_work(bytes.len() as u64)?; + state.signed.try_extend_from_slice(bytes)?; + Ok(()) + } + + fn finish_raw( + &self, + state: RawSignature<'a>, + execution: &W, + ) -> Result { + let RawSignature { + algorithm, + key, + signature, + signed, + } = state; + execution.charge_work((signed.len() + signature.len()) as u64)?; + // A BER constructed OCTET STRING is joined once, admitted first. + let joined; + let signature = if let Some(bytes) = signature.as_one() { + bytes + } else { + let mut bytes = execution.vector(); + bytes.try_reserve_exact(signature.len())?; + for piece in signature.pieces() { + bytes.try_extend_from_slice(piece.as_bytes())?; + } + joined = bytes; + &joined[..] + }; + let valid = super::pkix::with_effective_spki(key, execution, |spki| { + self.provider + .verify_x509_signature(algorithm, &signed, signature, spki) + .map_err(X509ChainError::from) + })?; + Ok(if valid { + SignatureAnswer::Valid + } else { + SignatureAnswer::Invalid + }) + } + + fn verify_prehashed( + &'a self, + _identity: &X509SignatureAlgorithm, + _key: WorkingKey<'a>, + _signature: &Octets<'a>, + _digest: Prehash<'_, '_, Self, DigestAlgorithm>, + _execution: &W, + ) -> Result { + // resolve_signature selects raw mode for every algorithm. + Err(CmsProviderError::PrehashNotSelected) + } +} diff --git a/src/xmldsig/mod.rs b/src/xmldsig/mod.rs index 9914834d..c762ebc7 100644 --- a/src/xmldsig/mod.rs +++ b/src/xmldsig/mod.rs @@ -58,6 +58,8 @@ #[cfg(feature = "xmldsig-sign")] pub mod builder; +#[cfg(feature = "cms")] +pub mod cms; pub mod digest; pub mod extension; pub mod keys; diff --git a/src/xmldsig/pkix.rs b/src/xmldsig/pkix.rs index 775c2eb3..e46d9b9b 100644 --- a/src/xmldsig/pkix.rs +++ b/src/xmldsig/pkix.rs @@ -789,7 +789,7 @@ fn materialize_effective_spki( Ok(output.expect("all parts completed")) } -fn with_effective_spki( +pub(crate) fn with_effective_spki( key: structured_pkix::path::WorkingKey<'_>, execution: &W, verify: impl FnOnce(&[u8]) -> Result, @@ -934,7 +934,7 @@ impl ProviderVerifier<'_> { } /// Digests this crate implements, by their shared PKIX identity. -fn digest_algorithm( +pub(crate) fn digest_algorithm( value: structured_pkix::algorithm::digest::Algorithm, ) -> Option { use structured_pkix::algorithm::digest::Algorithm as Hash; @@ -956,43 +956,48 @@ fn signature_algorithm( identifier: structured_pkix::algorithm::AlgorithmIdentifier<'_>, execution: &W, ) -> Result { + if let Some(algorithm) = known_signature_algorithm(identifier, execution)? { + return Ok(algorithm); + } + // Preserve branch-local discovery semantics for unknown identifiers. + // PKIX has already validated the OID's original encoding; its dotted + // text is rendered under the current operation's account. + let memory = execution.memory(); + let resource = |reason| { + X509ChainError::Processing(structured_pkix::Error::resource( + reason, + "certificate.signature", + identifier.offset(), + )) + }; + let text = identifier.algorithm().text(memory).map_err(resource)?; + Err(X509ChainError::UnsupportedSignatureAlgorithm { + oid: text.as_str(memory).map_err(resource)?.to_owned(), + }) +} + +/// The provider signature algorithm a PKIX identifier names, or `None` when +/// this crate implements no such algorithm or parameter set. +pub(crate) fn known_signature_algorithm( + identifier: structured_pkix::algorithm::AlgorithmIdentifier<'_>, + execution: &W, +) -> Result, X509ChainError> { use crate::provider::X509SignatureAlgorithm as Signature; use structured_asn1::digest::Resolution; - use structured_pkix::algorithm::{digest::Algorithm as Hash, signature::Algorithm}; - let unsupported = || { - // Preserve branch-local discovery semantics for unknown identifiers. - // PKIX has already validated the OID's original encoding; its dotted - // text is rendered under the current operation's account. - let memory = execution.memory(); - let resource = |reason| { - X509ChainError::Processing(structured_pkix::Error::resource( - reason, - "certificate.signature", - identifier.offset(), - )) - }; - match identifier.algorithm().text(memory) { - Ok(text) => match text.as_str(memory) { - Ok(oid) => X509ChainError::UnsupportedSignatureAlgorithm { - oid: oid.to_owned(), - }, - Err(reason) => resource(reason), - }, - Err(reason) => resource(reason), - } - }; - let hash = |value: Hash| digest_algorithm(value).ok_or_else(unsupported); + use structured_pkix::algorithm::signature::Algorithm; // Shared PKIX owns ASN.1 signature syntax and parameters, retaining the // original reader/depth and both source and current Work. The XML adapter // maps only provider capability; it never encodes/reparses the identifier. structured_pkix::algorithm::signature::with_algorithm(identifier, execution, |value| { Ok(match value { - Some(Algorithm::RsaPkcs1v15(value)) => Signature::RsaPkcs1v15(hash(value)?), - Some(Algorithm::Dsa(value)) => Signature::Dsa(hash(value)?), - Some(Algorithm::Ecdsa(value)) => Signature::Ecdsa(hash(value)?), - Some(Algorithm::Ed25519) => Signature::Ed25519, - Some(Algorithm::Ed448) => Signature::Ed448, - Some(Algorithm::MlDsa(set)) => Signature::PostQuantum(match set { + Some(Algorithm::RsaPkcs1v15(value)) => { + digest_algorithm(value).map(Signature::RsaPkcs1v15) + } + Some(Algorithm::Dsa(value)) => digest_algorithm(value).map(Signature::Dsa), + Some(Algorithm::Ecdsa(value)) => digest_algorithm(value).map(Signature::Ecdsa), + Some(Algorithm::Ed25519) => Some(Signature::Ed25519), + Some(Algorithm::Ed448) => Some(Signature::Ed448), + Some(Algorithm::MlDsa(set)) => Some(Signature::PostQuantum(match set { structured_pkix::algorithm::ml_dsa::ParameterSet::MlDsa44 => { super::PqAlgorithm::MlDsa44 } @@ -1002,24 +1007,32 @@ fn signature_algorithm( structured_pkix::algorithm::ml_dsa::ParameterSet::MlDsa87 => { super::PqAlgorithm::MlDsa87 } - }), + })), Some(Algorithm::RsaPss(parameters)) => { - let profile = match parameters + match parameters .profile(execution) .map_err(X509ChainError::Processing)? { - Resolution::Supported(profile) => profile, - Resolution::Unsupported | Resolution::Malformed => return Err(unsupported()), - }; - let salt_len = profile - .salt() - .as_u64() - .and_then(|value| usize::try_from(value).ok()) - .ok_or_else(unsupported)?; - Signature::RsaPss { - digest: hash(profile.hash())?, - mgf_digest: hash(profile.mask_hash())?, - salt_len, + Resolution::Supported(profile) => { + match ( + digest_algorithm(profile.hash()), + digest_algorithm(profile.mask_hash()), + profile + .salt() + .as_u64() + .and_then(|value| usize::try_from(value).ok()), + ) { + (Some(digest), Some(mgf_digest), Some(salt_len)) => { + Some(Signature::RsaPss { + digest, + mgf_digest, + salt_len, + }) + } + _ => None, + } + } + Resolution::Unsupported | Resolution::Malformed => None, } } None => { @@ -1031,31 +1044,28 @@ fn signature_algorithm( [0x2b, 0x0e, 0x03, 0x02, 0x1d] if parameters.is_none() || identifier.has_null_parameter() => { - Signature::RsaPkcs1v15(super::DigestAlgorithm::Sha1) + Some(Signature::RsaPkcs1v15(super::DigestAlgorithm::Sha1)) } [0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x03, 0x03] if parameters.is_none() => { - Signature::Dsa(super::DigestAlgorithm::Sha384) + Some(Signature::Dsa(super::DigestAlgorithm::Sha384)) } [0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x03, 0x04] if parameters.is_none() => { - Signature::Dsa(super::DigestAlgorithm::Sha512) + Some(Signature::Dsa(super::DigestAlgorithm::Sha512)) } - _ if parameters.is_none() => Signature::PostQuantum( - super::PqAlgorithm::ALL - .into_iter() - .find(|value| { - der::asn1::ObjectIdentifier::new_unwrap(value.oid()).as_bytes() - == oid - }) - .ok_or_else(unsupported)?, - ), - _ => return Err(unsupported()), + _ if parameters.is_none() => super::PqAlgorithm::ALL + .into_iter() + .find(|value| { + der::asn1::ObjectIdentifier::new_unwrap(value.oid()).as_bytes() == oid + }) + .map(Signature::PostQuantum), + _ => None, } } - _ => return Err(unsupported()), + _ => None, }) }) .map_err(X509ChainError::Processing)? diff --git a/src/xmldsig/verify/session/lookup.rs b/src/xmldsig/verify/session/lookup.rs index c8fff684..6aad91ae 100644 --- a/src/xmldsig/verify/session/lookup.rs +++ b/src/xmldsig/verify/session/lookup.rs @@ -35,7 +35,19 @@ pub struct SessionDigest<'p> { state: Box, } -impl SessionDigest<'_> { +impl<'p> SessionDigest<'p> { + /// Start `algorithm` on `provider` once `policy` permits it. + pub(crate) fn begin( + provider: &'p dyn crate::provider::CryptoProvider, + policy: &crate::policy::VerificationPolicy, + algorithm: DigestAlgorithm, + ) -> Result { + policy.check_digest_algorithm(algorithm)?; + let state = super::super::super::digest::begin_digest(provider, algorithm) + .map_err(DsigError::from)?; + Ok(Self { algorithm, state }) + } + /// Required digest output width, independent of provider output. #[must_use] pub fn output_len(&self) -> usize { @@ -161,10 +173,16 @@ impl VerifiedSession<'_, '_, '_, W> { /// Returns an error for a digest denied by policy, an unavailable provider /// capability, or failure to create the provider's incremental state. pub fn begin_digest(&self, algorithm: DigestAlgorithm) -> Result, DsigError> { - self.context.policy.check_digest_algorithm(algorithm)?; - let state = super::super::super::digest::begin_digest(self.context.provider, algorithm) - .map_err(DsigError::from)?; - Ok(SessionDigest { algorithm, state }) + SessionDigest::begin(self.context.provider, &self.context.policy, algorithm) + } + + /// CMS and RFC 3161 signature checks through this operation's provider, + /// digest and certificate-signature policy and RSA/DSA key policy. Each + /// call opens a new certificate account, as [`Self::certificate_parser`] + /// does, so one CMS processing session uses one provider. + #[cfg(feature = "cms")] + pub fn cms_provider(&self) -> crate::xmldsig::cms::CmsProvider<'_> { + crate::xmldsig::cms::CmsProvider::new(self.context.provider, &self.context.policy) } /// This verified request's own execution. A continuation that funds its diff --git a/tests/cms_timestamp.rs b/tests/cms_timestamp.rs new file mode 100644 index 00000000..0460d1d4 --- /dev/null +++ b/tests/cms_timestamp.rs @@ -0,0 +1,597 @@ +//! RFC 3161 tokens from an independent TSA (OpenSSL, regenerated by +//! scripts/generate-cms-timestamp-fixtures.sh) checked by the published CMS +//! timestamp processor through xml-sec's selected provider and policy. +#![cfg(feature = "cms")] + +use std::sync::Arc; + +use structured_asn1::memory::{Memory, MemoryAccount}; +use structured_asn1::{Encoding, Parser, digest::Resolution}; +use structured_cms::content_source::ContentError; +use structured_cms::digest::DigestError; +use structured_cms::ess::BindingError; +use structured_cms::prepare::PrepareError; +use structured_cms::provider::{SignatureAnswer, SignatureMode, SignatureProvider}; +use structured_cms::timestamp::{ + CheckContext, CheckError, ImprintAgreement, InfoCheckError, InfoError, SignatureError, Token, + TokenSignature, +}; +use structured_pkix::Certificate; +use structured_pkix::path::WorkingKey; +use structured_pkix::temporal::{Instant, LeapTable}; +use xml_sec::policy::{CertificateSignatureAlgorithms, VerificationPolicy}; +#[cfg(not(feature = "xmlenc"))] +use xml_sec::provider::{ + CryptoProvider, DigestState, KdfParameters, ProviderCapability, ProviderError, +}; +use xml_sec::provider::{RustCryptoProvider, X509SignatureAlgorithm}; +use xml_sec::xmldsig::DigestAlgorithm; +use xml_sec::xmldsig::cms::{CmsProvider, CmsProviderError}; +#[cfg(not(feature = "xmlenc"))] +use xml_sec::xmldsig::{DsigError, SignatureAlgorithm, SigningKey, SigningKeyError, VerifyingKey}; + +const AUTHORITIES: [&str; 4] = ["rsa", "rsa-pss", "ecdsa-p256", "ed25519"]; + +fn fixture(name: &str) -> Vec { + let path = format!( + "{}/tests/fixtures/cms/timestamp/{name}", + env!("CARGO_MANIFEST_DIR") + ); + std::fs::read(&path).unwrap_or_else(|error| panic!("{path}: {error}")) +} + +/// The facts of one completed check that the assertions below inspect. +#[derive(Debug, PartialEq, Eq)] +struct Outcome { + signature: Option, + imprint: ImprintAgreement, + evidence: bool, +} + +/// Why a check did not complete. +enum Refusal { + /// The xml-sec provider refused, with its rendered cause. + Provider(String), + /// Token reading or common processing refused. + Other(String), +} + +impl Refusal { + fn is_policy(&self) -> bool { + matches!(self, Self::Provider(cause) if cause.starts_with("Policy(")) + } +} + +impl core::fmt::Debug for Refusal { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Provider(cause) => write!(f, "provider refusal: {cause}"), + Self::Other(cause) => write!(f, "processing refusal: {cause}"), + } + } +} + +/// The provider failure inside a refused check, if the refusal was one: from +/// signer preparation, signature state admission or the ESS certificate hash. +fn provider_failure( + error: &CheckError, +) -> Option<&CmsProviderError> { + let CheckError::Information(ContentError::Callback { + source: InfoError::Callback(information), + .. + }) = error + else { + return None; + }; + match information { + InfoCheckError::Signature( + SignatureError::Preparation(PrepareError::Callback { source, .. }) + | SignatureError::Admission(structured_cms::verify::CheckError::Callback(source)), + ) + | InfoCheckError::Ess( + BindingError::Callback { source, .. } + | BindingError::Digest { + source: DigestError::Callback(source), + .. + }, + ) => Some(source), + _ => None, + } +} + +fn other(error: impl core::fmt::Display) -> Refusal { + Refusal::Other(error.to_string()) +} + +/// Check `token` of authority `name` over `covered` with `policy`, on an +/// account limited to `limits` (storage, work). The account must be empty +/// once the check and every value it produced are dropped. +fn check( + name: &str, + token: &[u8], + covered: &[u8], + policy: &VerificationPolicy, + limits: (usize, usize), +) -> Result { + let memory = Memory::new(Arc::new(MemoryAccount::new(limits.0, limits.1))); + let certificate_bytes = fixture(&format!("{name}.der")); + let result = (|| { + let parser = Parser::ber(memory.clone()); + let cert_parser = Parser::der(memory.clone()); + let token = Token::read(&parser, token, 0).map_err(other)?; + let parent = parser.read(&[], 0).map_err(other)?; + let certificate = Certificate::read(&cert_parser, &certificate_bytes, 0).map_err(other)?; + let certificate_parent = cert_parser.read(&[], 0).map_err(other)?; + let utc = LeapTable::new(0, 2_000_000_000, &[]).expect("UTC facts"); + let provider = RustCryptoProvider; + let cms = CmsProvider::new(&provider, policy); + let context = CheckContext { + certificate: &certificate, + certificate_parent: &certificate_parent, + key: WorkingKey::standing(certificate.tbs().public_key()), + requested: None, + utc: &utc, + reference: Some(Instant::new(1_800_000_000, 0).expect("instant")), + window: None, + }; + match token.check(&cms, &parent, &context, &mut [covered], |_, _| { + Ok::<_, ()>(false) + }) { + Ok(checked) => Ok(Outcome { + signature: match &checked.signature { + TokenSignature::Checked(checked) => checked.signature(), + TokenSignature::Unsupported(_) | TokenSignature::Malformed(_) => None, + }, + imprint: checked.imprint, + evidence: checked.evidence().is_some(), + }), + Err(error) => Err(match provider_failure(&error) { + Some(failure) => Refusal::Provider(format!("{failure:?}")), + None => Refusal::Other(format!("{error:?}")), + }), + } + })(); + assert_eq!(memory.used(), 0, "{name}: retained storage after the check"); + result +} + +fn generous() -> (usize, usize) { + (1 << 20, 1 << 24) +} + +fn permissive() -> VerificationPolicy { + let mut policy = VerificationPolicy::default(); + policy.key_trust.certificate_signature_algorithms = + Some(CertificateSignatureAlgorithms::AllSupported); + policy +} + +#[test] +fn every_independent_authority_token_verifies_through_the_selected_provider() { + // RSA PKCS #1 v1.5 (rsaEncryption), RSA-PSS, ECDSA P-256 and pure Ed25519 + // tokens from OpenSSL: each signature is valid, the imprint matches the + // payload and the common checker retains timestamp evidence. + let payload = fixture("payload.txt"); + for name in AUTHORITIES { + let outcome = check( + name, + &fixture(&format!("{name}.tst")), + &payload, + &permissive(), + generous(), + ) + .unwrap_or_else(|error| panic!("{name}: {error:?}")); + assert_eq!( + outcome, + Outcome { + signature: Some(SignatureAnswer::Valid), + imprint: ImprintAgreement::Matches, + evidence: true, + }, + "{name}" + ); + } +} + +#[test] +fn other_content_is_an_imprint_disagreement_not_an_error() { + // The signature stays valid; only the covered content differs, so no + // evidence is retained and nothing is reported as a processing error. + for name in AUTHORITIES { + let outcome = check( + name, + &fixture(&format!("{name}.tst")), + b"Another payload\n", + &permissive(), + generous(), + ) + .unwrap_or_else(|error| panic!("{name}: {error:?}")); + assert_eq!(outcome.signature, Some(SignatureAnswer::Valid), "{name}"); + assert_eq!(outcome.imprint, ImprintAgreement::Differs, "{name}"); + assert!(!outcome.evidence, "{name}"); + } +} + +#[test] +fn a_tampered_signature_value_is_invalid_not_an_error() { + // Each token ends with its SignerInfo signature; flipping the final octet + // keeps every encoding readable, so the arithmetic must reject it. + let payload = fixture("payload.txt"); + for name in AUTHORITIES { + let mut token = fixture(&format!("{name}.tst")); + let last = token.len() - 1; + token[last] ^= 0x01; + let outcome = check(name, &token, &payload, &permissive(), generous()) + .unwrap_or_else(|error| panic!("{name}: {error:?}")); + assert_eq!(outcome.signature, Some(SignatureAnswer::Invalid), "{name}"); + assert!(!outcome.evidence, "{name}"); + } +} + +#[test] +fn a_signature_algorithm_outside_the_policy_is_a_policy_violation() { + // Only Ed25519 certificate signatures are permitted: every other token is + // refused as a typed policy violation before any signed byte is read. + let mut policy = VerificationPolicy::default(); + policy.key_trust.certificate_signature_algorithms = + Some(CertificateSignatureAlgorithms::Allowlist( + [X509SignatureAlgorithm::Ed25519].into_iter().collect(), + )); + let payload = fixture("payload.txt"); + for name in AUTHORITIES { + let result = check( + name, + &fixture(&format!("{name}.tst")), + &payload, + &policy, + generous(), + ); + if name == "ed25519" { + assert!(result.is_ok_and(|outcome| outcome.evidence), "{name}"); + } else { + let refusal = result.expect_err(name); + assert!(refusal.is_policy(), "{name}: {refusal:?}"); + } + } +} + +#[test] +fn a_digest_outside_the_policy_is_a_policy_violation() { + // The Ed25519 token uses SHA-512 for its signer digest and ESS certificate + // hash; without SHA-512 in the digest policy the first of them to start is + // refused as a typed violation, never treated as a mismatch. + let mut policy = permissive(); + policy.digest_algorithms = Some([DigestAlgorithm::Sha256].into_iter().collect()); + let refusal = check( + "ed25519", + &fixture("ed25519.tst"), + &fixture("payload.txt"), + &policy, + generous(), + ) + .expect_err("SHA-512 is not permitted"); + assert!(refusal.is_policy(), "{refusal:?}"); +} + +#[test] +fn exhausted_work_is_a_refusal_never_a_negative_answer() { + // Below the work the complete check needs, the outcome is a refusal; it is + // never reported as an invalid signature or a different imprint. `check` + // asserts that every refused attempt released its storage, including the + // buffered signedAttrs. + let payload = fixture("payload.txt"); + let token = fixture("rsa.tst"); + let valid = Outcome { + signature: Some(SignatureAnswer::Valid), + imprint: ImprintAgreement::Matches, + evidence: true, + }; + let mut refused = 0; + for work in (0..=16).map(|shift| 1usize << shift) { + match check("rsa", &token, &payload, &permissive(), (1 << 20, work)) { + Ok(outcome) => assert_eq!(outcome, valid, "work {work}"), + Err(_) => refused += 1, + } + } + assert!(refused > 0, "no work ceiling refused the check"); + assert!(check("rsa", &token, &payload, &permissive(), generous()).is_ok_and(|o| o == valid)); +} + +#[test] +fn exhausted_storage_is_a_refusal_never_a_negative_answer() { + // As for work: below the storage the check needs the outcome is a refusal, + // and `check` asserts that the refused attempt retained nothing. + let payload = fixture("payload.txt"); + let token = fixture("rsa.tst"); + let mut refused = 0; + for storage in (0..=20).map(|shift| 1usize << shift) { + match check( + "rsa", + &token, + &payload, + &permissive(), + (storage, generous().1), + ) { + Ok(outcome) => assert!(outcome.evidence, "storage {storage}"), + Err(_) => refused += 1, + } + } + assert!(refused > 0, "no storage ceiling refused the check"); +} + +#[test] +fn an_identifier_that_names_no_signature_algorithm_is_unsupported() { + // id-ecPublicKey identifies a key, not a signature; it is neither a + // refusal nor malformed. + let memory = Memory::new(Arc::new(MemoryAccount::new(1 << 20, 1 << 24))); + let parser = Parser::der(memory); + let policy = permissive(); + let provider = RustCryptoProvider; + let cms = CmsProvider::new(&provider, &policy); + let parent = parser.read(&[], 0).expect("reader"); + let bytes = fixture("ecdsa-p256.der"); + let certificate = Certificate::read(&parser, &bytes, 0).expect("certificate"); + let key = certificate.tbs().public_key(); + assert!(matches!( + cms.resolve_signature( + key.algorithm(), + &DigestAlgorithm::Sha256, + WorkingKey::standing(key), + Encoding::Der, + &parent, + ), + Ok(Resolution::Unsupported) + )); +} + +/// The bundled provider without Ed25519 certificate signatures. Encryption +/// builds extend the provider contract; the `cms-xmloxide` profile runs this. +#[cfg(not(feature = "xmlenc"))] +struct WithoutEd25519; + +#[cfg(not(feature = "xmlenc"))] +impl CryptoProvider for WithoutEd25519 { + fn name(&self) -> &'static str { + "without-ed25519" + } + fn supports(&self, capability: ProviderCapability<'_>) -> bool { + !matches!( + capability, + ProviderCapability::VerifyCertificate(X509SignatureAlgorithm::Ed25519) + ) && RustCryptoProvider.supports(capability) + } + fn sign( + &self, + key: &dyn SigningKey, + algorithm: SignatureAlgorithm, + input: &[u8], + ) -> Result, SigningKeyError> { + RustCryptoProvider.sign(key, algorithm, input) + } + fn verify( + &self, + key: &dyn VerifyingKey, + algorithm: SignatureAlgorithm, + input: &[u8], + signature: &[u8], + ) -> Result { + RustCryptoProvider.verify(key, algorithm, input, signature) + } + fn fill_random(&self, output: &mut [u8]) -> Result<(), ProviderError> { + RustCryptoProvider.fill_random(output) + } + fn derive_key( + &self, + parameters: &KdfParameters<'_>, + secret: &[u8], + ) -> Result, ProviderError> { + RustCryptoProvider.derive_key(parameters, secret) + } + fn begin_digest( + &self, + algorithm: DigestAlgorithm, + ) -> Result, ProviderError> { + RustCryptoProvider.begin_digest(algorithm) + } +} + +#[cfg(not(feature = "xmlenc"))] +#[test] +fn an_algorithm_the_provider_lacks_is_unsupported_after_policy_permits_it() { + // Policy permits Ed25519 here, so only the capability check can answer. + let memory = Memory::new(Arc::new(MemoryAccount::new(1 << 20, 1 << 24))); + let parser = Parser::der(memory); + let policy = permissive(); + let cms = CmsProvider::new(&WithoutEd25519, &policy); + let parent = parser.read(&[], 0).expect("reader"); + let bytes = fixture("ed25519.der"); + let certificate = Certificate::read(&parser, &bytes, 0).expect("certificate"); + assert!(matches!( + cms.resolve_signature( + certificate.signature_algorithm(), + &DigestAlgorithm::Sha512, + WorkingKey::standing(certificate.tbs().public_key()), + Encoding::Der, + &parent, + ), + Ok(Resolution::Unsupported) + )); +} + +/// Resolve the signature identifier of certificate fixture `name` for a signer +/// whose `digestAlgorithm` is `digest`. +fn resolve_own_signature( + name: &str, + digest: DigestAlgorithm, +) -> Result>, CmsProviderError> { + let memory = Memory::new(Arc::new(MemoryAccount::new(1 << 20, 1 << 24))); + let parser = Parser::der(memory); + let policy = permissive(); + let provider = RustCryptoProvider; + let cms = CmsProvider::new(&provider, &policy); + let parent = parser.read(&[], 0).expect("reader"); + let bytes = fixture(&format!("{name}.der")); + let certificate = Certificate::read(&parser, &bytes, 0).expect("certificate"); + cms.resolve_signature( + certificate.signature_algorithm(), + &digest, + WorkingKey::standing(certificate.tbs().public_key()), + Encoding::Der, + &parent, + ) +} + +#[test] +fn ed25519_requires_a_sha512_signer_digest() { + // RFC 8419 3.1 and 3.2: the signature verifies over signedAttrs whatever + // digestAlgorithm says, so only this check refuses another digest. + assert!(matches!( + resolve_own_signature("ed25519", DigestAlgorithm::Sha256), + Ok(Resolution::Malformed) + )); + assert!(matches!( + resolve_own_signature("ed25519", DigestAlgorithm::Sha512), + Ok(Resolution::Supported(SignatureMode::Raw( + X509SignatureAlgorithm::Ed25519 + ))) + )); +} + +#[test] +fn ed448_refuses_every_digest_it_could_resolve() { + // RFC 8419 3.1 and 3.2 require SHAKE256, which is no digest identity here. + for digest in [DigestAlgorithm::Sha256, DigestAlgorithm::Sha512] { + assert!(matches!( + resolve_own_signature("ed448", digest), + Ok(Resolution::Malformed) + )); + } +} + +#[test] +fn rsa_pss_must_name_the_signer_digest() { + // RFC 4056 section 3: the hash of signedAttrs and hashAlgorithm of the + // RSASSA-PSS parameters MUST be the same. + assert!(matches!( + resolve_own_signature("rsa-pss-signed", DigestAlgorithm::Sha384), + Ok(Resolution::Malformed) + )); + assert!(matches!( + resolve_own_signature("rsa-pss-signed", DigestAlgorithm::Sha256), + Ok(Resolution::Supported(SignatureMode::Raw( + X509SignatureAlgorithm::RsaPss { .. } + ))) + )); +} + +#[test] +fn one_provider_charges_every_signer_to_one_certificate_account() { + // max_x509_work is cumulative for a processing session: with the smallest + // allowance one signer's key check fits, a second one through the same + // provider is refused instead of receiving a fresh allowance. + let memory = Memory::new(Arc::new(MemoryAccount::new(1 << 20, 1 << 24))); + let parser = Parser::der(memory); + let provider = RustCryptoProvider; + let parent = parser.read(&[], 0).expect("reader"); + let bytes = fixture("rsa.der"); + let certificate = Certificate::read(&parser, &bytes, 0).expect("certificate"); + let key = certificate.tbs().public_key(); + let smallest = (1..=4096) + .find(|&work| { + let mut policy = permissive(); + policy.resources.max_x509_work = work; + CmsProvider::new(&provider, &policy) + .resolve_signature( + certificate.signature_algorithm(), + &DigestAlgorithm::Sha256, + WorkingKey::standing(key), + Encoding::Der, + &parent, + ) + .is_ok() + }) + .expect("an allowance that fits one key check"); + let mut policy = permissive(); + policy.resources.max_x509_work = smallest; + let cms = CmsProvider::new(&provider, &policy); + let resolve = || { + cms.resolve_signature( + certificate.signature_algorithm(), + &DigestAlgorithm::Sha256, + WorkingKey::standing(key), + Encoding::Der, + &parent, + ) + }; + assert!(resolve().is_ok()); + assert!(resolve().is_err()); +} + +#[test] +fn ecdsa_must_name_the_signer_digest_and_rsa_encryption_takes_it() { + // RFC 5753 §2.1.1: ecdsa-with-SHA256 under a SHA-384 digestAlgorithm is + // malformed. RFC 3370 §3.2: rsaEncryption (NULL parameters) signs with + // whatever digest digestAlgorithm names. + let memory = Memory::new(Arc::new(MemoryAccount::new(1 << 20, 1 << 24))); + let parser = Parser::der(memory); + let policy = permissive(); + let provider = RustCryptoProvider; + let cms = CmsProvider::new(&provider, &policy); + let parent = parser.read(&[], 0).expect("reader"); + + let ecdsa_bytes = fixture("ecdsa-p256.der"); + let ecdsa = Certificate::read(&parser, &ecdsa_bytes, 0).expect("certificate"); + let key = WorkingKey::standing(ecdsa.tbs().public_key()); + let resolve = |digest| { + cms.resolve_signature( + ecdsa.signature_algorithm(), + &digest, + key, + Encoding::Der, + &parent, + ) + }; + assert!(matches!( + resolve(DigestAlgorithm::Sha384), + Ok(Resolution::Malformed) + )); + assert!(matches!( + resolve(DigestAlgorithm::Sha256), + Ok(Resolution::Supported(SignatureMode::Raw( + X509SignatureAlgorithm::Ecdsa(DigestAlgorithm::Sha256) + ))) + )); + + let rsa_bytes = fixture("rsa.der"); + let rsa = Certificate::read(&parser, &rsa_bytes, 0).expect("certificate"); + let rsa_key = rsa.tbs().public_key(); + assert!(matches!( + cms.resolve_signature( + rsa_key.algorithm(), + &DigestAlgorithm::Sha384, + WorkingKey::standing(rsa_key), + Encoding::Der, + &parent, + ), + Ok(Resolution::Supported(SignatureMode::Raw( + X509SignatureAlgorithm::RsaPkcs1v15(DigestAlgorithm::Sha384) + ))) + )); +} + +#[test] +fn a_weak_rsa_signer_key_is_a_policy_violation() { + // The TSA key is 2048-bit RSA; a 3072-bit minimum refuses it before the + // signature is computed. + let mut policy = permissive(); + policy.key_trust.rsa_keys.minimum_modulus_bits = 3072; + let refusal = check( + "rsa", + &fixture("rsa.tst"), + &fixture("payload.txt"), + &policy, + generous(), + ) + .expect_err("weak key"); + assert!(refusal.is_policy(), "{refusal:?}"); +} diff --git a/tests/fixtures/cms/timestamp/ecdsa-p256.der b/tests/fixtures/cms/timestamp/ecdsa-p256.der new file mode 100644 index 00000000..5b79695a Binary files /dev/null and b/tests/fixtures/cms/timestamp/ecdsa-p256.der differ diff --git a/tests/fixtures/cms/timestamp/ecdsa-p256.tst b/tests/fixtures/cms/timestamp/ecdsa-p256.tst new file mode 100644 index 00000000..ea85d49c Binary files /dev/null and b/tests/fixtures/cms/timestamp/ecdsa-p256.tst differ diff --git a/tests/fixtures/cms/timestamp/ed25519.der b/tests/fixtures/cms/timestamp/ed25519.der new file mode 100644 index 00000000..306943c7 Binary files /dev/null and b/tests/fixtures/cms/timestamp/ed25519.der differ diff --git a/tests/fixtures/cms/timestamp/ed25519.tst b/tests/fixtures/cms/timestamp/ed25519.tst new file mode 100644 index 00000000..a432d7e1 Binary files /dev/null and b/tests/fixtures/cms/timestamp/ed25519.tst differ diff --git a/tests/fixtures/cms/timestamp/ed448.der b/tests/fixtures/cms/timestamp/ed448.der new file mode 100644 index 00000000..384db52f Binary files /dev/null and b/tests/fixtures/cms/timestamp/ed448.der differ diff --git a/tests/fixtures/cms/timestamp/payload.txt b/tests/fixtures/cms/timestamp/payload.txt new file mode 100644 index 00000000..933d9753 --- /dev/null +++ b/tests/fixtures/cms/timestamp/payload.txt @@ -0,0 +1 @@ +Time-stamped payload diff --git a/tests/fixtures/cms/timestamp/rsa-pss-signed.der b/tests/fixtures/cms/timestamp/rsa-pss-signed.der new file mode 100644 index 00000000..87a611dd Binary files /dev/null and b/tests/fixtures/cms/timestamp/rsa-pss-signed.der differ diff --git a/tests/fixtures/cms/timestamp/rsa-pss.der b/tests/fixtures/cms/timestamp/rsa-pss.der new file mode 100644 index 00000000..761e638b Binary files /dev/null and b/tests/fixtures/cms/timestamp/rsa-pss.der differ diff --git a/tests/fixtures/cms/timestamp/rsa-pss.tst b/tests/fixtures/cms/timestamp/rsa-pss.tst new file mode 100644 index 00000000..ddb22ecf Binary files /dev/null and b/tests/fixtures/cms/timestamp/rsa-pss.tst differ diff --git a/tests/fixtures/cms/timestamp/rsa.der b/tests/fixtures/cms/timestamp/rsa.der new file mode 100644 index 00000000..0fc59fd9 Binary files /dev/null and b/tests/fixtures/cms/timestamp/rsa.der differ diff --git a/tests/fixtures/cms/timestamp/rsa.tst b/tests/fixtures/cms/timestamp/rsa.tst new file mode 100644 index 00000000..5a1280dd Binary files /dev/null and b/tests/fixtures/cms/timestamp/rsa.tst differ diff --git a/tools/xml-sec-tools/src/check_features.rs b/tools/xml-sec-tools/src/check_features.rs index 2ca0bbc5..ed13c75b 100644 --- a/tools/xml-sec-tools/src/check_features.rs +++ b/tools/xml-sec-tools/src/check_features.rs @@ -9,8 +9,9 @@ mod tests; /// Crates a verification-only build must not compile: signing, private-key /// import, password derivation, an OS randomness source and a second -/// certificate reader. -pub(crate) const VERIFY_ONLY_FORBIDDEN: [&str; 8] = [ +/// certificate reader. The `cms` feature admits only `structured-cms`, the +/// last entry. +pub(crate) const VERIFY_ONLY_FORBIDDEN: [&str; 9] = [ "pkcs12", "pkcs5", "pbkdf2", @@ -19,8 +20,14 @@ pub(crate) const VERIFY_ONLY_FORBIDDEN: [&str; 8] = [ "x509-cert", "x509-parser", "getrandom", + "structured-cms", ]; +const CMS_FORBIDDEN: &[&str] = VERIFY_ONLY_FORBIDDEN + .split_last() + .expect("the list is not empty") + .1; + const BACKEND_DIAGNOSTIC: &str = "compile at least one XML backend"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -52,8 +59,8 @@ impl Phase { pub(crate) enum Action { /// A process that must succeed. Run(Step), - /// A `cargo tree` listing that must name no verification-only refusal. - VerifyOnlyGraph(Step), + /// A `cargo tree` listing that must name none of the refused crates. + VerifyOnlyGraph(Step, &'static [&'static str]), /// A build that must fail with the missing-backend diagnostic. MissingBackend(Step), } @@ -67,7 +74,7 @@ struct Profile { facade: bool, } -const PROFILES: [Profile; 15] = [ +const PROFILES: [Profile; 16] = [ profile("default", None, &["rust_api"], false), profile("dom-xmloxide", Some("xml-backend-xmloxide"), &[], false), profile("dom-roxmltree", Some("xml-backend-roxmltree"), &[], false), @@ -95,6 +102,12 @@ const PROFILES: [Profile; 15] = [ &["verify_only_build"], false, ), + profile( + "cms-xmloxide", + Some("cms,xml-backend-xmloxide"), + &["cms_timestamp"], + false, + ), profile( "dsig-sign-xmloxide", Some("xmldsig-sign,xml-backend-xmloxide"), @@ -191,18 +204,22 @@ pub(crate) fn plan( ); } let mut actions = Vec::new(); - if selected.targets.contains(&"verify_only_build") && phase.includes(Phase::Lint) { + let forbidden: Option<&'static [&'static str]> = match selected.targets { + ["verify_only_build"] => Some(&VERIFY_ONLY_FORBIDDEN), + ["cms_timestamp"] => Some(CMS_FORBIDDEN), + _ => None, + }; + if let Some(forbidden) = forbidden.filter(|_| phase.includes(Phase::Lint)) { let tree = [ &["tree"][..], &args, &["-e", "normal", "--prefix", "none", "--format", "{p}"], ] .concat(); - actions.push(Action::VerifyOnlyGraph(Step::new( - "dependency graph", - "cargo", - &tree, - ))); + actions.push(Action::VerifyOnlyGraph( + Step::new("dependency graph", "cargo", &tree), + forbidden, + )); } if phase.includes(Phase::Lint) { let clippy = [ @@ -229,10 +246,10 @@ pub(crate) fn plan( Ok(actions) } -/// The first crate of `graph` (one `name vVERSION` per line) a -/// verification-only build must not contain. -pub(crate) fn forbidden_crate(graph: &str) -> Option<&'static str> { - VERIFY_ONLY_FORBIDDEN.into_iter().find(|name| { +/// The first crate of `forbidden` that `graph` (one `name vVERSION` per line) +/// contains. +pub(crate) fn forbidden_crate(graph: &str, forbidden: &[&'static str]) -> Option<&'static str> { + forbidden.iter().copied().find(|name| { graph.lines().any(|line| { line.strip_prefix(name) .is_some_and(|rest| rest.starts_with(" v")) @@ -245,10 +262,10 @@ pub(crate) fn execute(actions: &[Action], runner: &mut impl Run) -> Result<(), F for action in actions { match action { Action::Run(step) => require(runner.status(step)?)?, - Action::VerifyOnlyGraph(step) => { + Action::VerifyOnlyGraph(step, forbidden) => { let (status, graph) = runner.stdout(step)?; require(status)?; - if let Some(name) = forbidden_crate(&graph) { + if let Some(name) = forbidden_crate(&graph, forbidden) { return Err(format!("verification-only graph contains {name}").into()); } } diff --git a/tools/xml-sec-tools/src/check_features/tests.rs b/tools/xml-sec-tools/src/check_features/tests.rs index b76eb63f..810709af 100644 --- a/tools/xml-sec-tools/src/check_features/tests.rs +++ b/tools/xml-sec-tools/src/check_features/tests.rs @@ -8,8 +8,9 @@ fn lines(actions: &[Action]) -> Vec { actions .iter() .map(|action| { - let (Action::Run(step) | Action::VerifyOnlyGraph(step) | Action::MissingBackend(step)) = - action; + let (Action::Run(step) + | Action::VerifyOnlyGraph(step, _) + | Action::MissingBackend(step)) = action; step.args.join(" ") }) .collect() @@ -71,7 +72,7 @@ fn a_verification_only_graph_with_signing_or_import_crates_fails_before_lint() { // The verify-only feature exists to keep these crates out of the dependency // graph; one reappearing through any edge must fail qualification. let actions = plan("dsig-xmloxide", "lint", None).unwrap(); - assert!(matches!(actions[0], Action::VerifyOnlyGraph(_))); + assert!(matches!(actions[0], Action::VerifyOnlyGraph(..))); for name in VERIFY_ONLY_FORBIDDEN { let mut runner = Recorded { output: format!("xml-sec v0.2.0\n{name} v1.0.0\n"), @@ -89,7 +90,10 @@ fn a_verification_only_graph_with_signing_or_import_crates_fails_before_lint() { } // A crate whose name merely starts with a forbidden name is not a match. assert_eq!( - forbidden_crate("xml-sec v0.2.0\npkcs12-extra v1.0.0\n"), + forbidden_crate( + "xml-sec v0.2.0\npkcs12-extra v1.0.0\n", + &VERIFY_ONLY_FORBIDDEN + ), None ); let mut prefixed = Recorded { @@ -99,6 +103,44 @@ fn a_verification_only_graph_with_signing_or_import_crates_fails_before_lint() { execute(&actions, &mut prefixed).unwrap(); } +#[test] +fn only_the_cms_profile_may_compile_the_cms_processor() { + // Plain XMLDSig verification stays free of CMS; the cms profile adds it + // but keeps every signing and import crate out of its graph. + let graph = "xml-sec v0.2.0\nstructured-cms v0.18.0\n"; + let run = |profile: &str, output: &str| { + let actions = plan(profile, "lint", None).unwrap(); + execute( + &actions, + &mut Recorded { + output: output.into(), + ..Recorded::default() + }, + ) + }; + assert_eq!( + run("dsig-roxmltree", graph).unwrap_err().message, + "verification-only graph contains structured-cms" + ); + run("cms-xmloxide", graph).unwrap(); + assert_eq!( + run("cms-xmloxide", "xml-sec v0.2.0\npkcs12 v0.2.0\n") + .unwrap_err() + .message, + "verification-only graph contains pkcs12" + ); +} + +#[test] +fn the_cms_profile_runs_its_real_token_tests_without_the_signing_facade_test() { + assert_eq!( + planned("cms-xmloxide", "test"), + [ + "nextest run -p xml-sec --no-default-features --features cms,xml-backend-xmloxide --lib --test cms_timestamp" + ] + ); +} + #[test] fn default_qualification_uses_actual_defaults_rather_than_a_hand_built_equivalent() { // A newly added default feature must automatically enter this gate. @@ -161,6 +203,7 @@ fn each_minimal_profile_selects_only_its_declared_feature_graph() { ("c14n-roxmltree", "c14n,xml-backend-roxmltree"), ("dsig-xmloxide", "xmldsig,xml-backend-xmloxide"), ("dsig-roxmltree", "xmldsig,xml-backend-roxmltree"), + ("cms-xmloxide", "cms,xml-backend-xmloxide"), ("dsig-sign-xmloxide", "xmldsig-sign,xml-backend-xmloxide"), ("xmlenc-xmloxide", "xmlenc,xml-backend-xmloxide"), ("xmlenc-roxmltree", "xmlenc,xml-backend-roxmltree"), @@ -177,7 +220,9 @@ fn each_minimal_profile_selects_only_its_declared_feature_graph() { ("pkcs11", "pkcs11,xml-backend-xmloxide"), ] { let mut expected = Vec::new(); - if profile.starts_with("dsig-") && !profile.starts_with("dsig-sign") { + if profile.starts_with("cms-") + || profile.starts_with("dsig-") && !profile.starts_with("dsig-sign") + { expected.push(format!( "tree -p xml-sec --no-default-features --features {features} -e normal --prefix none --format {{p}}" ));