From ecbabc8c5bb68829e2239357e3c62389e2f83b38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sun, 11 Oct 2026 01:10:45 +0900 Subject: [PATCH 01/30] feat(picky-crypto): add the RustCrypto backend Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 31 +- Cargo.lock | 69 ++++ Cargo.toml | 2 + picky-crypto-conformance/Cargo.toml | 6 + picky-crypto-conformance/tests/main.rs | 2 + picky-crypto-rustcrypto/AGENTS.md | 7 + picky-crypto-rustcrypto/Cargo.toml | 59 +++ picky-crypto-rustcrypto/INTENT.md | 15 + picky-crypto-rustcrypto/README.md | 55 +++ picky-crypto-rustcrypto/deny.toml | 6 + picky-crypto-rustcrypto/src/aead.rs | 91 +++++ picky-crypto-rustcrypto/src/cbc.rs | 100 +++++ picky-crypto-rustcrypto/src/curve25519.rs | 209 +++++++++++ picky-crypto-rustcrypto/src/ec.rs | 221 +++++++++++ picky-crypto-rustcrypto/src/hash.rs | 65 ++++ picky-crypto-rustcrypto/src/key_wrap.rs | 75 ++++ picky-crypto-rustcrypto/src/lib.rs | 88 +++++ picky-crypto-rustcrypto/src/mac.rs | 74 ++++ picky-crypto-rustcrypto/src/pbkdf2.rs | 35 ++ picky-crypto-rustcrypto/src/random.rs | 14 + picky-crypto-rustcrypto/src/rc4.rs | 47 +++ picky-crypto-rustcrypto/src/rsa.rs | 425 ++++++++++++++++++++++ picky-crypto-rustcrypto/src/util.rs | 89 +++++ picky-crypto/CONTRACT.md | 16 +- 24 files changed, 1793 insertions(+), 8 deletions(-) create mode 100644 picky-crypto-rustcrypto/AGENTS.md create mode 100644 picky-crypto-rustcrypto/Cargo.toml create mode 100644 picky-crypto-rustcrypto/INTENT.md create mode 100644 picky-crypto-rustcrypto/README.md create mode 100644 picky-crypto-rustcrypto/deny.toml create mode 100644 picky-crypto-rustcrypto/src/aead.rs create mode 100644 picky-crypto-rustcrypto/src/cbc.rs create mode 100644 picky-crypto-rustcrypto/src/curve25519.rs create mode 100644 picky-crypto-rustcrypto/src/ec.rs create mode 100644 picky-crypto-rustcrypto/src/hash.rs create mode 100644 picky-crypto-rustcrypto/src/key_wrap.rs create mode 100644 picky-crypto-rustcrypto/src/lib.rs create mode 100644 picky-crypto-rustcrypto/src/mac.rs create mode 100644 picky-crypto-rustcrypto/src/pbkdf2.rs create mode 100644 picky-crypto-rustcrypto/src/random.rs create mode 100644 picky-crypto-rustcrypto/src/rc4.rs create mode 100644 picky-crypto-rustcrypto/src/rsa.rs create mode 100644 picky-crypto-rustcrypto/src/util.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11decf94..000e5eee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -227,7 +227,7 @@ jobs: run: cargo --locked xtask check-vectors - name: Run conformance suite - run: cargo test --locked -p picky-crypto-conformance -- --show-output + run: cargo test --locked -p picky-crypto-conformance --features rustcrypto -- --show-output - name: Install cargo-deny run: cargo install --locked cargo-deny --version 0.20.2 @@ -235,10 +235,37 @@ jobs: - name: Check dependency allowlist run: cargo deny --manifest-path picky-crypto/Cargo.toml --config picky-crypto/deny.toml check bans + picky-crypto-rustcrypto: + name: RustCrypto backend checks + needs: [formatting] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + submodules: true + + - name: Install cargo-hack + run: cargo install --locked cargo-hack --version 0.6.45 + + - name: Check feature powerset + run: cargo hack clippy --locked --feature-powerset --depth 2 -p picky-crypto-rustcrypto --all-targets -- -D warnings + + - name: Check documentation + run: cargo doc --locked -p picky-crypto-rustcrypto --no-deps --all-features + env: + RUSTDOCFLAGS: "-D warnings" + + - name: Install cargo-deny + run: cargo install --locked cargo-deny --version 0.20.2 + + - name: Check banned features + run: cargo deny --manifest-path Cargo.toml --workspace --all-features --config picky-crypto-rustcrypto/deny.toml check bans + success: name: Success if: ${{ always() }} - needs: [formatting, lints, lints-wasm, tests, msrv, dotnet-tests, no-downgrade, picky-crypto-checks] + needs: [formatting, lints, lints-wasm, tests, msrv, dotnet-tests, no-downgrade, picky-crypto-checks, picky-crypto-rustcrypto] runs-on: ubuntu-latest steps: diff --git a/Cargo.lock b/Cargo.lock index ec565512..bb33e93a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,7 @@ dependencies = [ "cipher", "cpubits", "cpufeatures", + "zeroize", ] [[package]] @@ -41,6 +42,7 @@ dependencies = [ "ctr", "ctutils", "ghash", + "zeroize", ] [[package]] @@ -51,6 +53,7 @@ checksum = "41ac571010bd60765c56085a4f1d412012a9be2663b1a2f2b19b49318653fd0d" dependencies = [ "aes", "const-oid", + "zeroize", ] [[package]] @@ -152,6 +155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] @@ -237,6 +241,7 @@ dependencies = [ "cfg-if", "cpufeatures", "rand_core", + "zeroize", ] [[package]] @@ -259,6 +264,7 @@ dependencies = [ "block-buffer", "crypto-common", "inout", + "zeroize", ] [[package]] @@ -436,6 +442,7 @@ dependencies = [ "const-oid", "crypto-common", "ctutils", + "zeroize", ] [[package]] @@ -507,7 +514,9 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ + "pkcs8", "signature", + "zeroize", ] [[package]] @@ -519,6 +528,7 @@ dependencies = [ "curve25519-dalek", "ed25519", "rand_core", + "serde", "sha2", "signature", "subtle", @@ -733,6 +743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" dependencies = [ "polyval", + "zeroize", ] [[package]] @@ -1112,6 +1123,15 @@ dependencies = [ "digest", ] +[[package]] +name = "md4" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd76fb0fd6b2e4be62a73f8e0858ca97f81babcb1af322dcaca196f735f17f80" +dependencies = [ + "digest", +] + [[package]] name = "memchr" version = "2.8.3" @@ -1373,9 +1393,45 @@ name = "picky-crypto-conformance" version = "0.0.0" dependencies = [ "picky-crypto", + "picky-crypto-rustcrypto", "picky-crypto-testsuite", ] +[[package]] +name = "picky-crypto-rustcrypto" +version = "0.1.0" +dependencies = [ + "aes", + "aes-gcm", + "aes-kw", + "cbc", + "chacha20", + "cipher", + "crypto-common", + "des", + "digest", + "ed25519", + "ed25519-dalek", + "getrandom 0.4.3", + "hmac", + "md-5", + "md4", + "p256", + "p384", + "p521", + "pbkdf2", + "picky-crypto", + "rc2", + "rc4", + "rsa", + "sec1", + "sha1", + "sha2", + "sha3", + "x25519-dalek", + "zeroize", +] + [[package]] name = "picky-crypto-testsuite" version = "0.0.0" @@ -1474,6 +1530,7 @@ dependencies = [ "cpubits", "cpufeatures", "universal-hash", + "zeroize", ] [[package]] @@ -1631,6 +1688,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "rc4" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840038b674daa9f7a7957440d937951d15c0143c056e631e529141fd780e0c92" +dependencies = [ + "cipher", +] + [[package]] name = "regex" version = "1.13.1" @@ -1980,6 +2046,9 @@ name = "sponge-cursor" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" +dependencies = [ + "zeroize", +] [[package]] name = "stable_deref_trait" diff --git a/Cargo.toml b/Cargo.toml index 9fc1b796..b7e5c557 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "picky-crypto", "picky-crypto-testsuite", "picky-crypto-conformance", + "picky-crypto-rustcrypto", "picky", "picky-krb", "picky-asn1", @@ -17,6 +18,7 @@ members = [ default-members = [ "picky-crypto", + "picky-crypto-rustcrypto", "picky", "picky-krb", "picky-asn1", diff --git a/picky-crypto-conformance/Cargo.toml b/picky-crypto-conformance/Cargo.toml index 552cbfce..c3dde638 100644 --- a/picky-crypto-conformance/Cargo.toml +++ b/picky-crypto-conformance/Cargo.toml @@ -15,6 +15,12 @@ name = "conformance" path = "tests/main.rs" harness = true +[features] +rustcrypto = ["dep:picky-crypto-rustcrypto"] + +[dependencies] +picky-crypto-rustcrypto = { path = "../picky-crypto-rustcrypto", features = ["all"], optional = true } + [dev-dependencies] picky-crypto = { path = "../picky-crypto" } picky-crypto-testsuite = { path = "../picky-crypto-testsuite" } diff --git a/picky-crypto-conformance/tests/main.rs b/picky-crypto-conformance/tests/main.rs index bbe4d613..fd6ebd39 100644 --- a/picky-crypto-conformance/tests/main.rs +++ b/picky-crypto-conformance/tests/main.rs @@ -102,6 +102,8 @@ fn empty() -> CryptoProvider { providers! { empty ("none") => empty(), Options::default(); + #[cfg(feature = "rustcrypto")] + rustcrypto ("rustcrypto") => picky_crypto_rustcrypto::provider(), Options::default(); } pairs! { diff --git a/picky-crypto-rustcrypto/AGENTS.md b/picky-crypto-rustcrypto/AGENTS.md new file mode 100644 index 00000000..c52262bc --- /dev/null +++ b/picky-crypto-rustcrypto/AGENTS.md @@ -0,0 +1,7 @@ +# Backend maintenance + +- Inline tests cover adapter logic only: argument mapping, length and constant-byte checks. + Do not retest behavior covered by the conformance suite or the library. +- Never modify `picky-crypto-testsuite` to make this backend pass. + Escalate a failing case with its cited source, vector identifier, expected outcome and native library behavior. +- Cite the source file or documentation page at the version locked in `Cargo.lock` when relying on a library capability, or mark it Unknown. diff --git a/picky-crypto-rustcrypto/Cargo.toml b/picky-crypto-rustcrypto/Cargo.toml new file mode 100644 index 00000000..7d66f920 --- /dev/null +++ b/picky-crypto-rustcrypto/Cargo.toml @@ -0,0 +1,59 @@ +[package] +name = "picky-crypto-rustcrypto" +version = "0.1.0" +edition = "2024" +publish = false +rust-version = "1.85" +authors = ["Devolutions Inc. "] +license = "MIT OR Apache-2.0" +repository = "https://github.com/Devolutions/picky-rs" +description = "RustCrypto backend for the picky cryptographic provider contract" +readme = "README.md" +keywords = ["cryptography", "provider", "crypto"] +categories = ["cryptography"] + +[features] +digest = ["dep:sha1", "dep:sha2", "dep:sha3", "dep:hmac", "dep:pbkdf2", "dep:digest", "dep:crypto-common"] +aes = ["dep:aes", "dep:cbc", "dep:cipher"] +aead = ["dep:aes-gcm", "dep:aes", "dep:getrandom", "dep:crypto-common"] +key-wrap = ["dep:aes-kw", "dep:aes"] +rsa = ["dep:rsa", "dep:md-5", "dep:sha1", "dep:sha2", "dep:sha3", "dep:getrandom", "dep:chacha20", "dep:crypto-common"] +nist-ec = ["dep:p256", "dep:p384", "dep:p521", "dep:sha2", "dep:hmac", "dep:getrandom", "dep:crypto-common", "dep:sec1"] +curve25519 = ["dep:ed25519-dalek", "dep:ed25519", "dep:x25519-dalek", "dep:sha2", "dep:getrandom", "dep:crypto-common"] +legacy = ["dep:md4", "dep:md-5", "dep:rc4", "dep:des", "dep:rc2", "dep:cbc", "dep:cipher", "dep:digest"] +all = ["digest", "aes", "aead", "key-wrap", "rsa", "nist-ec", "curve25519", "legacy"] + +[dependencies] +picky-crypto = { path = "../picky-crypto" } +zeroize = { version = "1.9", default-features = false, features = ["alloc"] } +crypto-common = { version = "0.2.2", optional = true, features = ["rand_core", "zeroize"] } +aes = { version = "0.9.2", optional = true, features = ["zeroize"] } +aes-gcm = { version = "0.11.1", optional = true, default-features = false, features = ["aes", "alloc", "rand_core", "zeroize"] } +aes-kw = { version = "0.3.1", optional = true, features = ["zeroize"] } +cbc = { version = "0.2.1", optional = true, default-features = false, features = ["zeroize"] } +cipher = { version = "0.5.2", optional = true, features = ["zeroize"] } +digest = { version = "0.11.3", optional = true, features = ["zeroize"] } +des = { version = "0.9.0", optional = true, features = ["zeroize"] } +rc2 = { version = "0.9.0", optional = true, features = ["zeroize"] } +rc4 = { version = "0.2.0", optional = true, features = ["zeroize"] } +md4 = { version = "0.11.0", optional = true, features = ["zeroize"] } +md-5 = { version = "0.11.0", optional = true, features = ["oid", "zeroize"] } +sha1 = { version = "0.11.0", optional = true, features = ["oid", "zeroize"] } +sha2 = { version = "0.11.0", optional = true, features = ["oid", "zeroize"] } +sha3 = { version = "0.12.0", optional = true, features = ["oid", "zeroize"] } +hmac = { version = "0.13.0", optional = true, features = ["zeroize"] } +pbkdf2 = { version = "0.13.0", optional = true, default-features = false, features = ["hmac"] } +rsa = { version = "=0.10.0-rc.18", optional = true } +chacha20 = { version = "0.10.2", optional = true, default-features = false, features = ["rng", "zeroize"] } +sec1 = { version = "0.8.1", optional = true, default-features = false, features = ["der", "zeroize"] } +p256 = { version = "0.14.0", optional = true, default-features = false, features = ["ecdsa", "ecdh", "pkcs8", "alloc", "precomputed-tables", "std"] } +p384 = { version = "0.14.0", optional = true, default-features = false, features = ["ecdsa", "ecdh", "pkcs8", "alloc", "precomputed-tables", "std"] } +p521 = { version = "0.14.0", optional = true, default-features = false, features = ["ecdsa", "ecdh", "pkcs8", "alloc", "precomputed-tables", "std"] } +ed25519-dalek = { version = "3.0.0", optional = true, default-features = false, features = ["alloc", "digest", "fast", "pkcs8", "rand_core", "zeroize"] } +ed25519 = { version = "3.0.0", optional = true, default-features = false, features = ["pkcs8", "zeroize"] } +x25519-dalek = { version = "3.0.0", optional = true, default-features = false, features = ["precomputed-tables", "static_secrets", "zeroize"] } +getrandom = { version = "0.4.3", optional = true, features = ["sys_rng"] } + +[dev-dependencies] +# Test keys outside the library's checked constructors (small moduli, large exponents). +rsa = { version = "=0.10.0-rc.18", features = ["hazmat"] } diff --git a/picky-crypto-rustcrypto/INTENT.md b/picky-crypto-rustcrypto/INTENT.md new file mode 100644 index 00000000..9a5a5826 --- /dev/null +++ b/picky-crypto-rustcrypto/INTENT.md @@ -0,0 +1,15 @@ +# picky-crypto-rustcrypto + +## Purpose + +`picky-crypto` backend implemented on top of the RustCrypto crates. + +It implements every algorithm of the contract that RustCrypto provides when the `all` feature is enabled, including legacy algorithms (MD4, MD5, RC4, 3DES, RC2) needed by NTLM, Kerberos and legacy PKCS#12. +No family is enabled by default; each feature adds a family of entries. + +## Invariants + +- A backend is a thin adapter. + Real logic in a backend means the contract doesn't fit that library, and that's a reason to revisit the contract. +- It passes the full `picky-crypto-testsuite` conformance suite. +- `fips()` always returns `false`. diff --git a/picky-crypto-rustcrypto/README.md b/picky-crypto-rustcrypto/README.md new file mode 100644 index 00000000..09eb6c51 --- /dev/null +++ b/picky-crypto-rustcrypto/README.md @@ -0,0 +1,55 @@ +# picky-crypto-rustcrypto + +A thin RustCrypto backend for the [picky-crypto contract]. +Call `picky_crypto_rustcrypto::provider()` to build a provider with the enabled capability families. +Nothing is enabled by default; `all` enables every family below. +Every entry, loader and private key reports `fips() == false`. + +Parsing and encoding formats needs no provider; only code that performs cryptographic operations through `picky-crypto` does. + +## Features and the library + +The library consists of the crates below, at the versions locked by the workspace. +Dependencies needed internally do not enable additional provider entries. +MAC, CBC, AES-GCM and AES-KW entries support both applying and processing protection. + +| Feature | Provider entries | Library crates | +|---|---|---| +| `digest` | SHA-1/224/256/384/512, SHA3-384/512; HMAC-SHA-1/224/256/384/512; PBKDF2 with those HMACs | sha1 0.11.0, sha2 0.11.0, sha3 0.12.0, digest 0.11.3, hmac 0.13.0, pbkdf2 0.13.0 | +| `aes` | AES-128/192/256-CBC without padding | aes 0.9.2, cbc 0.2.1, cipher 0.5.2 | +| `aead` | AES-128/192/256-GCM; secure random | aes-gcm 0.11.1, aes 0.9.2, getrandom 0.4.3 | +| `key-wrap` | AES-128/192/256-KW | aes-kw 0.3.1, aes 0.9.2 | +| `rsa` | PKCS#1 v1.5 verification with MD5, SHA-1/224/256/384/512 and SHA3-384/512; PKCS#1 v1.5 and OAEP-SHA-1/256 encryption; RSA private-key loading and 2048/3072/4096-bit generation; secure random | rsa 0.10.0-rc.18, md-5 0.11.0, sha1 0.11.0, sha2 0.11.0, sha3 0.12.0, getrandom 0.4.3, chacha20 0.10.2 | +| `nist-ec` | P-256/SHA-256, P-384/SHA-384 and P-521/SHA-512 ECDSA verification; ECDH, private-key loading and generation for those curves; secure random | p256/p384/p521 0.14.0, sha2 0.11.0, hmac 0.13.0, getrandom 0.4.3 | +| `curve25519` | Ed25519 verification, private-key loading and generation; X25519 ephemeral agreement and private-key loading; secure random | ed25519-dalek/ed25519/x25519-dalek 3.0.0, sha2 0.11.0, getrandom 0.4.3 | +| `legacy` | MD4, MD5, RC4, 3DES-EDE3-CBC and RC2-CBC | md4/md-5 0.11.0, digest 0.11.3, rc4 0.2.0, des/rc2 0.9.0, cbc 0.2.1, cipher 0.5.2 | +| `all` | Every family above | Every dependency above | + +The library also includes these support crates, some reached through re-exports: aead 0.6.1, crypto-common 0.2.2, hybrid-array 0.4.15, typenum 1.20.1, crypto-bigint 0.7.5, elliptic-curve 0.14.1, ecdsa 0.17.0, signature 3.0.0, curve25519-dalek 5.0.0, pkcs8 0.11.0, spki 0.8.0, sec1 0.8.1, der 0.8.2, const-oid 0.10.2, rand_core 0.10.1 and zeroize 1.9.0. +PKCS#1 encoding is reached through rsa's pkcs1 0.8.0-rc.4 re-export. +Secret cleanup features are enabled wherever the library offers them, and adapter-owned secret copies and returned buffers use zeroizing storage. + +Loaded RSA keys sign with every listed RSA signature algorithm, decrypt with every listed RSA encryption algorithm and export their public key. +Loaded EC keys sign and agree on their own curve and export their public key. +Loaded Ed25519 keys sign and export their public key; loaded X25519 keys agree and export their public key. +Enabling ed25519-dalek's `legacy_compatibility` feature anywhere in a build weakens this backend's Ed25519 signature `S` range check, because Cargo unifies features; this workspace bans it with cargo-deny. + +Key-based KDFs and finite-field Diffie-Hellman are not provided; they come from other providers. +RSA key generation draws a 256-bit seed from the operating-system generator and expands it with ChaCha20, so a generator failure is reported instead of panicking. +RSA signing and decryption blind the private-key operation with the operating-system generator. +RSA public keys beyond the rsa crate's limits (a modulus above 8192 bits or a public exponent above 2^33 − 1) are unsupported. +Private-key loading applies the same limits, although the rsa crate loads private keys of any modulus size. + +## Conformance tests + +The `picky-crypto-conformance` runner tests this backend with `all` through its `rustcrypto` feature. +Initialize the published-vector submodule before running it: + +```console +git submodule update --init +cargo test -p picky-crypto-conformance --features rustcrypto +``` + +This crate's own tests cover adapter logic only. + +[picky-crypto contract]: ../picky-crypto/CONTRACT.md diff --git a/picky-crypto-rustcrypto/deny.toml b/picky-crypto-rustcrypto/deny.toml new file mode 100644 index 00000000..72713193 --- /dev/null +++ b/picky-crypto-rustcrypto/deny.toml @@ -0,0 +1,6 @@ +[bans] +multiple-versions = "allow" + +[[bans.features]] +crate = "ed25519-dalek" +deny = ["legacy_compatibility"] diff --git a/picky-crypto-rustcrypto/src/aead.rs b/picky-crypto-rustcrypto/src/aead.rs new file mode 100644 index 00000000..f13c2dd1 --- /dev/null +++ b/picky-crypto-rustcrypto/src/aead.rs @@ -0,0 +1,91 @@ +use aes_gcm::{ + AesGcm, + aead::{AeadInOut, Generate, Key, KeyInit, Nonce, consts::U12}, +}; +use picky_crypto::{Aead, AeadAlgorithm, Entry, Error, OutputBytes, Protection, ProviderBuilder, Sealed, Zeroizing}; +use std::sync::Arc; + +struct Gcm(AeadAlgorithm); +impl Gcm { + fn seal_with(key: &[u8], aad: &[u8], plaintext: &[u8]) -> Result { + let key: &Key = key.try_into().map_err(|_| Error::InvalidKey)?; + check_lengths(aad.len(), plaintext.len())?; + let cipher = C::new(key); + let nonce = Zeroizing::new( + Nonce::::try_generate_from_rng(&mut getrandom::SysRng).map_err(|_| Error::ProviderFailure)?, + ); + let capacity = plaintext.len().checked_add(16).ok_or(Error::InvalidInput)?; + let mut output = crate::util::buffer(capacity)?; + output.truncate(plaintext.len()); + output.copy_from_slice(plaintext); + cipher + .encrypt_in_place(&nonce, aad, &mut *output) + .map_err(|_| Error::InvalidInput)?; + Ok(Sealed::new(crate::util::output(&nonce)?, OutputBytes::new(output))) + } + + fn open_with( + key: &[u8], + nonce: &[u8], + aad: &[u8], + ciphertext: &[u8], + ) -> Result { + let key: &Key = key.try_into().map_err(|_| Error::InvalidKey)?; + if ciphertext.len() < 16 { + return Err(Error::InvalidInput); + } + check_lengths(aad.len(), ciphertext.len() - 16)?; + let nonce = nonce.try_into().map_err(|_| Error::InvalidInput)?; + let cipher = C::new(key); + let mut output = crate::util::output(ciphertext)?.into_inner(); + cipher + .decrypt_in_place(nonce, aad, &mut *output) + .map_err(|_| Error::VerificationFailed)?; + Ok(OutputBytes::new(output)) + } +} + +fn check_lengths(aad: usize, plaintext: usize) -> Result<(), Error> { + if aad as u128 > u128::from(aes_gcm::A_MAX) || plaintext as u128 > u128::from(aes_gcm::P_MAX) { + return Err(Error::InvalidInput); + } + Ok(()) +} +impl Aead for Gcm { + fn algorithm(&self) -> AeadAlgorithm { + self.0 + } + fn fips(&self) -> bool { + false + } + fn supports(&self, protection: Protection) -> bool { + crate::util::both(protection) + } + fn seal(&self, key: &[u8], aad: &[u8], plaintext: &[u8]) -> Result { + match self.0 { + AeadAlgorithm::Aes128Gcm => Self::seal_with::>(key, aad, plaintext), + AeadAlgorithm::Aes192Gcm => Self::seal_with::>(key, aad, plaintext), + AeadAlgorithm::Aes256Gcm => Self::seal_with::>(key, aad, plaintext), + _ => Err(Error::Unsupported(picky_crypto::Algorithm::Aead(self.0))), + } + } + fn open(&self, key: &[u8], nonce: &[u8], aad: &[u8], ciphertext: &[u8]) -> Result { + match self.0 { + AeadAlgorithm::Aes128Gcm => Self::open_with::>(key, nonce, aad, ciphertext), + AeadAlgorithm::Aes192Gcm => Self::open_with::>(key, nonce, aad, ciphertext), + AeadAlgorithm::Aes256Gcm => Self::open_with::>(key, nonce, aad, ciphertext), + _ => Err(Error::Unsupported(picky_crypto::Algorithm::Aead(self.0))), + } + } +} +pub(crate) fn entries(builder: ProviderBuilder) -> ProviderBuilder { + [ + AeadAlgorithm::Aes128Gcm, + AeadAlgorithm::Aes192Gcm, + AeadAlgorithm::Aes256Gcm, + ] + .into_iter() + .fold(builder, |builder, algorithm| { + builder.with(Entry::Aead(Arc::new(Gcm(algorithm)))) + }) +} diff --git a/picky-crypto-rustcrypto/src/cbc.rs b/picky-crypto-rustcrypto/src/cbc.rs new file mode 100644 index 00000000..625e901c --- /dev/null +++ b/picky-crypto-rustcrypto/src/cbc.rs @@ -0,0 +1,100 @@ +use cipher::{Block, BlockCipherDecrypt, BlockCipherEncrypt, BlockModeDecrypt, BlockModeEncrypt, InnerIvInit, KeyInit}; +use picky_crypto::{Cipher, CipherAlgorithm, Entry, Error, OutputBytes, Protection, ProviderBuilder}; +use std::sync::Arc; + +struct Cbc(CipherAlgorithm); +impl Cbc { + fn run(&self, key: &[u8], iv: &[u8], data: &[u8], encrypt: bool) -> Result { + macro_rules! fixed { + ($cipher:ty, $key_len:expr, $block_len:expr) => {{ + check(key.len() == $key_len, data, $block_len)?; + transform( + <$cipher>::new_from_slice(key).map_err(|_| Error::InvalidKey)?, + iv, + data, + encrypt, + ) + }}; + } + match self.0 { + #[cfg(feature = "aes")] + CipherAlgorithm::Aes128Cbc => fixed!(aes::Aes128, 16, 16), + #[cfg(feature = "aes")] + CipherAlgorithm::Aes192Cbc => fixed!(aes::Aes192, 24, 16), + #[cfg(feature = "aes")] + CipherAlgorithm::Aes256Cbc => fixed!(aes::Aes256, 32, 16), + #[cfg(feature = "legacy")] + CipherAlgorithm::TdesEde3Cbc => fixed!(des::TdesEde3, 24, 8), + #[cfg(feature = "legacy")] + CipherAlgorithm::Rc2Cbc => { + check((1..=128).contains(&key.len()), data, 8)?; + transform(rc2::Rc2::new_with_eff_key_len(key, 8 * key.len()), iv, data, encrypt) + } + _ => Err(Error::Unsupported(picky_crypto::Algorithm::Cipher(self.0))), + } + } +} + +fn check(key_valid: bool, data: &[u8], block: usize) -> Result<(), Error> { + if !key_valid { + return Err(Error::InvalidKey); + } + if data.len() % block != 0 { + return Err(Error::InvalidInput); + } + Ok(()) +} + +fn transform( + cipher: C, + iv: &[u8], + data: &[u8], + encrypt: bool, +) -> Result { + let iv = iv.try_into().map_err(|_| Error::InvalidInput)?; + let mut output = crate::util::output(data)?.into_inner(); + let blocks = Block::::slice_as_chunks_mut(&mut output).0; + if encrypt { + cbc::Encryptor::inner_iv_init(cipher, iv).encrypt_blocks(blocks); + } else { + cbc::Decryptor::inner_iv_init(cipher, iv).decrypt_blocks(blocks); + } + Ok(OutputBytes::new(output)) +} + +impl Cipher for Cbc { + fn algorithm(&self) -> CipherAlgorithm { + self.0 + } + fn fips(&self) -> bool { + false + } + fn supports(&self, protection: Protection) -> bool { + crate::util::both(protection) + } + fn encrypt(&self, key: &[u8], iv: &[u8], plaintext: &[u8]) -> Result { + self.run(key, iv, plaintext, true) + } + fn decrypt(&self, key: &[u8], iv: &[u8], ciphertext: &[u8]) -> Result { + self.run(key, iv, ciphertext, false) + } +} +pub(crate) fn entries(builder: ProviderBuilder) -> ProviderBuilder { + #[cfg(feature = "aes")] + let builder = [ + CipherAlgorithm::Aes128Cbc, + CipherAlgorithm::Aes192Cbc, + CipherAlgorithm::Aes256Cbc, + ] + .into_iter() + .fold(builder, |builder, algorithm| { + builder.with(Entry::Cipher(Arc::new(Cbc(algorithm)))) + }); + #[cfg(feature = "legacy")] + let builder = [CipherAlgorithm::TdesEde3Cbc, CipherAlgorithm::Rc2Cbc] + .into_iter() + .fold(builder, |builder, algorithm| { + builder.with(Entry::Cipher(Arc::new(Cbc(algorithm)))) + }); + builder +} diff --git a/picky-crypto-rustcrypto/src/curve25519.rs b/picky-crypto-rustcrypto/src/curve25519.rs new file mode 100644 index 00000000..111d62b1 --- /dev/null +++ b/picky-crypto-rustcrypto/src/curve25519.rs @@ -0,0 +1,209 @@ +use ed25519_dalek::{ + Signature, Signer, SigningKey, Verifier, VerifyingKey, + pkcs8::{EncodePrivateKey, PrivateKeyInfoRef, spki::der::Decode}, +}; +use picky_crypto::{ + Algorithm, Entry, EphemeralSecret, Error, KeyAgreement, KeyAgreementAlgorithm, KeyGenerationAlgorithm, + KeyGenerator, KeyOperation, KeyType, OutputBytes, PrivateKey, PrivateKeyLoader, PrivateKeyMaterial, + ProviderBuilder, PublicKey, SignatureAlgorithm, SignatureVerifier, Zeroizing, +}; +use std::sync::Arc; + +struct Verify; +struct EdLoader; +struct XLoader; +struct XAgreement; +struct Generator; +struct EdKey(SigningKey); +struct XKey(x25519_dalek::StaticSecret); + +impl SignatureVerifier for Verify { + fn algorithm(&self) -> SignatureAlgorithm { + SignatureAlgorithm::Ed25519 + } + fn fips(&self) -> bool { + false + } + fn verify(&self, public: PublicKey<'_>, message: &[u8], signature: &[u8]) -> Result<(), Error> { + let public = public.0.try_into().map_err(|_| Error::InvalidKey)?; + let key = VerifyingKey::from_bytes(public).map_err(|_| Error::InvalidKey)?; + // The library accepts non-canonical A encodings (ZIP-215, ed25519-dalek 3.0.0, src\verifying.rs:172–182), + // so the adapter compares the library's canonical recompression with the input. + if key.to_edwards().compress().as_bytes() != public { + return Err(Error::InvalidKey); + } + let signature = Signature::from_slice(signature).map_err(|_| Error::VerificationFailed)?; + key.verify(message, &signature).map_err(|_| Error::VerificationFailed) + } +} +impl PrivateKeyLoader for EdLoader { + fn key_type(&self) -> KeyType { + KeyType::Ed25519 + } + fn fips(&self) -> bool { + false + } + fn load(&self, material: PrivateKeyMaterial<'_>) -> Result, Error> { + let PrivateKeyMaterial::Pkcs8(der) = material else { + return Err(Error::InvalidKey); + }; + let info = PrivateKeyInfoRef::from_der(der).map_err(|_| Error::InvalidKey)?; + // Ed25519's decoder ignores non-aligned public keys and rejects other lengths + // (ed25519 3.0.0, src\pkcs8.rs:180–185). + // as_bytes rejects unused bits (der 0.8.2, src\asn1\bit_string.rs:121–127). + if info.public_key.is_some_and(|public| public.as_bytes().is_none()) { + return Err(Error::InvalidKey); + } + // The library checks that an embedded public key matches (ed25519-dalek 3.0.0, src\signing.rs:748–759). + let key = SigningKey::try_from(info).map_err(|_| Error::InvalidKey)?; + Ok(Box::new(EdKey(key))) + } +} +impl PrivateKey for EdKey { + fn key_type(&self) -> KeyType { + KeyType::Ed25519 + } + fn key_size_bits(&self) -> usize { + 255 + } + fn fips(&self) -> bool { + false + } + fn supports(&self, operation: KeyOperation) -> bool { + matches!( + operation, + KeyOperation::Sign(SignatureAlgorithm::Ed25519) | KeyOperation::PublicKey + ) + } + fn sign(&self, algorithm: SignatureAlgorithm, message: &[u8]) -> Result { + if algorithm != SignatureAlgorithm::Ed25519 { + return Err(Error::Unsupported(Algorithm::Signature(algorithm))); + } + let bytes = Zeroizing::new(self.0.try_sign(message).map_err(|_| Error::InvalidKey)?.to_bytes()); + crate::util::output(&*bytes) + } + fn public_key(&self) -> Result { + crate::util::output(self.0.verifying_key().as_bytes()) + } +} +impl PrivateKeyLoader for XLoader { + fn key_type(&self) -> KeyType { + KeyType::X25519 + } + fn fips(&self) -> bool { + false + } + fn load(&self, material: PrivateKeyMaterial<'_>) -> Result, Error> { + let PrivateKeyMaterial::X25519(scalar) = material else { + return Err(Error::InvalidKey); + }; + Ok(Box::new(XKey(x25519_dalek::StaticSecret::from(*scalar)))) + } +} +impl PrivateKey for XKey { + fn key_type(&self) -> KeyType { + KeyType::X25519 + } + fn key_size_bits(&self) -> usize { + 255 + } + fn fips(&self) -> bool { + false + } + fn supports(&self, operation: KeyOperation) -> bool { + matches!( + operation, + KeyOperation::Agree(KeyAgreementAlgorithm::X25519) | KeyOperation::PublicKey + ) + } + fn agree(&self, algorithm: KeyAgreementAlgorithm, bytes: &[u8]) -> Result { + if algorithm != KeyAgreementAlgorithm::X25519 { + return Err(Error::Unsupported(Algorithm::KeyAgreement(algorithm))); + } + let public: [u8; 32] = bytes.try_into().map_err(|_| Error::InvalidInput)?; + let secret = self.0.diffie_hellman(&x25519_dalek::PublicKey::from(public)); + if !secret.was_contributory() { + return Err(Error::InvalidInput); + } + crate::util::output(secret.as_bytes()) + } + fn public_key(&self) -> Result { + crate::util::output(x25519_dalek::PublicKey::from(&self.0).as_bytes()) + } +} +impl KeyAgreement for XAgreement { + fn algorithm(&self) -> KeyAgreementAlgorithm { + KeyAgreementAlgorithm::X25519 + } + fn fips(&self) -> bool { + false + } + fn generate_ephemeral(&self) -> Result, Error> { + // Mirrors x25519-dalek 3.0.0's EphemeralSecret::random(): 32 bytes from getrandom, clamping left to the library. + // Returns random-generator failures instead of panicking (src\x25519.rs:96–101). + let mut scalar = Zeroizing::new([0u8; 32]); + getrandom::fill(&mut *scalar).map_err(|_| Error::ProviderFailure)?; + Ok(Box::new(XKey(x25519_dalek::StaticSecret::from(*scalar)))) + } +} +impl EphemeralSecret for XKey { + fn public_key(&self) -> Result { + PrivateKey::public_key(self) + } + fn agree(self: Box, bytes: &[u8]) -> Result { + PrivateKey::agree(&*self, KeyAgreementAlgorithm::X25519, bytes) + } +} +impl KeyGenerator for Generator { + fn algorithm(&self) -> KeyGenerationAlgorithm { + KeyGenerationAlgorithm::Ed25519 + } + fn fips(&self) -> bool { + false + } + fn generate(&self) -> Result { + use crypto_common::Generate; + let key = SigningKey::try_generate_from_rng(&mut getrandom::SysRng).map_err(|_| Error::ProviderFailure)?; + let document = key.to_pkcs8_der().map_err(|_| Error::ProviderFailure)?; + crate::util::output(document.as_bytes()) + } +} +pub(crate) fn entries(builder: ProviderBuilder) -> ProviderBuilder { + builder + .with(Entry::SignatureVerifier(Arc::new(Verify))) + .with(Entry::PrivateKeyLoader(Arc::new(EdLoader))) + .with(Entry::PrivateKeyLoader(Arc::new(XLoader))) + .with(Entry::KeyAgreement(Arc::new(XAgreement))) + .with(Entry::KeyGenerator(Arc::new(Generator))) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519::pkcs8::{BitStringRef, spki::der::Encode}; + + #[test] + fn unaligned_outer_public_key_is_rejected() { + let seed = Zeroizing::new([1u8; 32]); + let document = SigningKey::from_bytes(&seed).to_pkcs8_der().unwrap(); + let mut info = PrivateKeyInfoRef::from_der(document.as_bytes()).unwrap(); + info.public_key = Some(BitStringRef::new(1, &[0; 32]).unwrap()); + let encoded = Zeroizing::new(info.to_der().unwrap()); + assert!(matches!( + EdLoader.load(PrivateKeyMaterial::Pkcs8(&encoded)), + Err(Error::InvalidKey) + )); + } + + #[test] + fn non_canonical_public_key_is_rejected() { + // y = p + 1 with p = 2^255 − 19, little-endian. + let mut public = [0xff; 32]; + public[0] = 0xee; + public[31] = 0x7f; + assert!(matches!( + Verify.verify(PublicKey(&public), b"", &[0; 64]), + Err(Error::InvalidKey) + )); + } +} diff --git a/picky-crypto-rustcrypto/src/ec.rs b/picky-crypto-rustcrypto/src/ec.rs new file mode 100644 index 00000000..73226639 --- /dev/null +++ b/picky-crypto-rustcrypto/src/ec.rs @@ -0,0 +1,221 @@ +use picky_crypto::{ + Algorithm, Entry, EphemeralSecret, Error, KeyAgreement, KeyAgreementAlgorithm, KeyGenerationAlgorithm, + KeyGenerator, KeyOperation, KeyType, OutputBytes, PrivateKey, PrivateKeyLoader, PrivateKeyMaterial, + ProviderBuilder, PublicKey, SignatureAlgorithm, SignatureVerifier, Zeroizing, +}; +use std::sync::Arc; + +fn uncompressed(bytes: &[u8], len: usize, error: Error) -> Result<(), Error> { + if bytes.len() != len || bytes.first() != Some(&4) { + return Err(error); + } + Ok(()) +} + +macro_rules! curve { + ($module:ident, $library:ident, $key_type:ident, $sign:ident, $agree:ident, $generate:ident, $bits:expr, $point_len:expr, $signature_len:expr) => { + mod $module { + use super::*; + use $library::{ + SecretKey, ecdh, + ecdsa::{ + Signature, SigningKey, VerifyingKey, + signature::{Signer, Verifier}, + }, + elliptic_curve::{Generate, sec1::ToSec1Point}, + pkcs8::{EncodePrivateKey, PrivateKeyInfoRef, Version, der::Decode}, + }; + struct Verify; + struct Agreement; + struct Loader; + struct Generator; + struct Key(SecretKey); + struct Ephemeral(ecdh::EphemeralSecret); + + impl SignatureVerifier for Verify { + fn algorithm(&self) -> SignatureAlgorithm { + SignatureAlgorithm::$sign + } + fn fips(&self) -> bool { + false + } + fn verify(&self, public: PublicKey<'_>, message: &[u8], signature: &[u8]) -> Result<(), Error> { + uncompressed(public.0, $point_len, Error::InvalidKey)?; + let key = VerifyingKey::from_sec1_bytes(public.0).map_err(|_| Error::InvalidKey)?; + // Rejects lengths other than 2 × the field size (ecdsa 0.17.0, src\lib.rs:212–216). + let signature = Signature::from_slice(signature).map_err(|_| Error::VerificationFailed)?; + key.verify(message, &signature) + .map_err(|_| Error::VerificationFailed) + } + } + fn peer(bytes: &[u8]) -> Result<$library::PublicKey, Error> { + uncompressed(bytes, $point_len, Error::InvalidInput)?; + $library::PublicKey::from_sec1_bytes(bytes).map_err(|_| Error::InvalidInput) + } + impl PrivateKeyLoader for Loader { + fn key_type(&self) -> KeyType { + KeyType::$key_type + } + fn fips(&self) -> bool { + false + } + fn load(&self, material: PrivateKeyMaterial<'_>) -> Result, Error> { + let PrivateKeyMaterial::Pkcs8(der) = material else { + return Err(Error::InvalidKey); + }; + let info = PrivateKeyInfoRef::from_der(der).map_err(|_| Error::InvalidKey)?; + if info.version() != Version::V1 { + return Err(Error::InvalidKey); + } + let inner = + sec1::EcPrivateKey::from_der(info.private_key.as_bytes()).map_err(|_| Error::InvalidKey)?; + // SecretKey::from_slice zero-pads short scalars (elliptic-curve 0.14.1, src\secret_key.rs:163–173). + if inner.private_key.len() != $signature_len / 2 { + return Err(Error::InvalidKey); + } + // The library checks the curve OIDs and that an embedded public key matches + // (elliptic-curve 0.14.1, src\secret_key\pkcs8.rs:53–59; src\secret_key.rs:465–484). + let key = SecretKey::try_from(info).map_err(|_| Error::InvalidKey)?; + Ok(Box::new(Key(key))) + } + } + impl PrivateKey for Key { + fn key_type(&self) -> KeyType { + KeyType::$key_type + } + fn key_size_bits(&self) -> usize { + $bits + } + fn fips(&self) -> bool { + false + } + fn supports(&self, operation: KeyOperation) -> bool { + matches!( + operation, + KeyOperation::Sign(SignatureAlgorithm::$sign) + | KeyOperation::Agree(KeyAgreementAlgorithm::$agree) + | KeyOperation::PublicKey + ) + } + fn sign(&self, algorithm: SignatureAlgorithm, message: &[u8]) -> Result { + if algorithm != SignatureAlgorithm::$sign { + return Err(Error::Unsupported(Algorithm::Signature(algorithm))); + } + let signing = SigningKey::from(&self.0); + let signature: Signature = signing.try_sign(message).map_err(|_| Error::InvalidKey)?; + let bytes = Zeroizing::new(signature.to_bytes()); + crate::util::output(&bytes) + } + fn agree(&self, algorithm: KeyAgreementAlgorithm, bytes: &[u8]) -> Result { + if algorithm != KeyAgreementAlgorithm::$agree { + return Err(Error::Unsupported(Algorithm::KeyAgreement(algorithm))); + } + let secret = self.0.diffie_hellman(&peer(bytes)?); + crate::util::output(secret.raw_secret_bytes()) + } + fn public_key(&self) -> Result { + crate::util::output(self.0.public_key().to_sec1_point(false).as_bytes()) + } + } + impl KeyAgreement for Agreement { + fn algorithm(&self) -> KeyAgreementAlgorithm { + KeyAgreementAlgorithm::$agree + } + fn fips(&self) -> bool { + false + } + fn generate_ephemeral(&self) -> Result, Error> { + let secret = ecdh::EphemeralSecret::try_generate_from_rng(&mut getrandom::SysRng) + .map_err(|_| Error::ProviderFailure)?; + Ok(Box::new(Ephemeral(secret))) + } + } + impl EphemeralSecret for Ephemeral { + fn public_key(&self) -> Result { + crate::util::output(self.0.public_key().to_sec1_point(false).as_bytes()) + } + fn agree(self: Box, bytes: &[u8]) -> Result { + let secret = self.0.diffie_hellman(&peer(bytes)?); + crate::util::output(secret.raw_secret_bytes()) + } + } + impl KeyGenerator for Generator { + fn algorithm(&self) -> KeyGenerationAlgorithm { + KeyGenerationAlgorithm::$generate + } + fn fips(&self) -> bool { + false + } + fn generate(&self) -> Result { + let key = + SecretKey::try_generate_from_rng(&mut getrandom::SysRng).map_err(|_| Error::ProviderFailure)?; + let document = key.to_pkcs8_der().map_err(|_| Error::ProviderFailure)?; + crate::util::output(document.as_bytes()) + } + } + pub(super) fn entries(builder: ProviderBuilder) -> ProviderBuilder { + builder + .with(Entry::SignatureVerifier(Arc::new(Verify))) + .with(Entry::KeyAgreement(Arc::new(Agreement))) + .with(Entry::PrivateKeyLoader(Arc::new(Loader))) + .with(Entry::KeyGenerator(Arc::new(Generator))) + } + + #[cfg(test)] + #[test] + fn short_private_scalar_is_rejected() { + use $library::pkcs8::der::{Encode, asn1::OctetStringRef}; + let scalar = Zeroizing::new([1u8; $signature_len / 2]); + let document = SecretKey::from_slice(&*scalar).unwrap().to_pkcs8_der().unwrap(); + let mut info = PrivateKeyInfoRef::from_der(document.as_bytes()).unwrap(); + let mut inner = sec1::EcPrivateKey::from_der(info.private_key.as_bytes()).unwrap(); + inner.private_key = &scalar[1..]; + inner.public_key = None; + let encoded_inner = Zeroizing::new(inner.to_der().unwrap()); + info.private_key = OctetStringRef::new(&encoded_inner).unwrap(); + let encoded = Zeroizing::new(info.to_der().unwrap()); + assert!(matches!( + Loader.load(PrivateKeyMaterial::Pkcs8(&encoded)), + Err(Error::InvalidKey) + )); + } + } + }; +} +curve!( + p256_adapter, + p256, + EcP256, + EcdsaP256Sha256, + EcdhP256, + EcP256, + 256, + 65, + 64 +); +curve!( + p384_adapter, + p384, + EcP384, + EcdsaP384Sha384, + EcdhP384, + EcP384, + 384, + 97, + 96 +); +curve!( + p521_adapter, + p521, + EcP521, + EcdsaP521Sha512, + EcdhP521, + EcP521, + 521, + 133, + 132 +); + +pub(crate) fn entries(builder: ProviderBuilder) -> ProviderBuilder { + p521_adapter::entries(p384_adapter::entries(p256_adapter::entries(builder))) +} diff --git a/picky-crypto-rustcrypto/src/hash.rs b/picky-crypto-rustcrypto/src/hash.rs new file mode 100644 index 00000000..62f7b2ae --- /dev/null +++ b/picky-crypto-rustcrypto/src/hash.rs @@ -0,0 +1,65 @@ +use digest::Digest; +use picky_crypto::{Entry, Error, Hash, HashAlgorithm, HashContext, OutputBytes, ProviderBuilder, Zeroizing}; +use std::{marker::PhantomData, sync::Arc}; + +struct HashEntry(HashAlgorithm, PhantomData); +struct Context { + digest: D, + length: crate::util::Length, +} + +impl Hash for HashEntry { + fn algorithm(&self) -> HashAlgorithm { + self.0 + } + fn fips(&self) -> bool { + false + } + fn start(&self) -> Result, Error> { + let limit = match self.0 { + HashAlgorithm::Md4 + | HashAlgorithm::Md5 + | HashAlgorithm::Sha1 + | HashAlgorithm::Sha224 + | HashAlgorithm::Sha256 => Some(u128::from(u64::MAX / 8)), + HashAlgorithm::Sha384 | HashAlgorithm::Sha512 => Some(u128::MAX / 8), + _ => None, + }; + Ok(Box::new(Context { + digest: D::default(), + length: crate::util::Length::new(limit), + })) + } +} + +impl HashContext for Context { + fn update(&mut self, data: &[u8]) -> Result<(), Error> { + self.length.update(data.len())?; + self.digest.update(data); + Ok(()) + } + fn finish(self: Box) -> Result { + self.length.check()?; + let digest = Zeroizing::new(self.digest.finalize()); + crate::util::output(&digest) + } +} + +pub(crate) fn entries(builder: ProviderBuilder) -> ProviderBuilder { + macro_rules! add { + ($builder:ident, $($algorithm:ident => $digest:ty),+ $(,)?) => { + $builder $(.with(Entry::Hash(Arc::new(HashEntry::<$digest>( + HashAlgorithm::$algorithm, PhantomData + )))))+ + }; + } + #[cfg(feature = "digest")] + let builder = add!(builder, + Sha1 => sha1::Sha1, Sha224 => sha2::Sha224, Sha256 => sha2::Sha256, + Sha384 => sha2::Sha384, Sha512 => sha2::Sha512, + Sha3_384 => sha3::Sha3_384, Sha3_512 => sha3::Sha3_512 + ); + #[cfg(feature = "legacy")] + let builder = add!(builder, Md4 => md4::Md4, Md5 => md5::Md5); + builder +} diff --git a/picky-crypto-rustcrypto/src/key_wrap.rs b/picky-crypto-rustcrypto/src/key_wrap.rs new file mode 100644 index 00000000..fb900702 --- /dev/null +++ b/picky-crypto-rustcrypto/src/key_wrap.rs @@ -0,0 +1,75 @@ +use aes_kw::{ + AesKw, KeyInit, + cipher::{BlockCipherDecrypt, BlockCipherEncrypt, Key, consts::U16}, +}; +use picky_crypto::{Entry, Error, KeyWrap, KeyWrapAlgorithm, OutputBytes, Protection, ProviderBuilder}; +use std::sync::Arc; + +struct Kw(KeyWrapAlgorithm); +impl Kw { + fn run + BlockCipherDecrypt + KeyInit>( + kek: &[u8], + data: &[u8], + wrap: bool, + ) -> Result { + let kek: &Key = kek.try_into().map_err(|_| Error::InvalidKey)?; + let len = if wrap { + if !matches!(data.len(), 16 | 24 | 32) { + return Err(Error::InvalidInput); + } + data.len() + 8 + } else { + if !matches!(data.len(), 24 | 32 | 40) { + return Err(Error::InvalidInput); + } + data.len() - 8 + }; + let cipher = AesKw::::new(kek); + let mut output = crate::util::buffer(len)?; + if wrap { + cipher.wrap_key(data, &mut output).map_err(|_| Error::InvalidInput)?; + } else { + cipher.unwrap_key(data, &mut output).map_err(|error| match error { + aes_kw::Error::IntegrityCheckFailed => Error::VerificationFailed, + _ => Error::InvalidInput, + })?; + } + Ok(OutputBytes::new(output)) + } + fn transform(&self, kek: &[u8], data: &[u8], wrap: bool) -> Result { + match self.0 { + KeyWrapAlgorithm::Aes128Kw => Self::run::(kek, data, wrap), + KeyWrapAlgorithm::Aes192Kw => Self::run::(kek, data, wrap), + KeyWrapAlgorithm::Aes256Kw => Self::run::(kek, data, wrap), + _ => Err(Error::Unsupported(picky_crypto::Algorithm::KeyWrap(self.0))), + } + } +} +impl KeyWrap for Kw { + fn algorithm(&self) -> KeyWrapAlgorithm { + self.0 + } + fn fips(&self) -> bool { + false + } + fn supports(&self, protection: Protection) -> bool { + crate::util::both(protection) + } + fn wrap(&self, kek: &[u8], data: &[u8]) -> Result { + self.transform(kek, data, true) + } + fn unwrap(&self, kek: &[u8], data: &[u8]) -> Result { + self.transform(kek, data, false) + } +} +pub(crate) fn entries(builder: ProviderBuilder) -> ProviderBuilder { + [ + KeyWrapAlgorithm::Aes128Kw, + KeyWrapAlgorithm::Aes192Kw, + KeyWrapAlgorithm::Aes256Kw, + ] + .into_iter() + .fold(builder, |builder, algorithm| { + builder.with(Entry::KeyWrap(Arc::new(Kw(algorithm)))) + }) +} diff --git a/picky-crypto-rustcrypto/src/lib.rs b/picky-crypto-rustcrypto/src/lib.rs new file mode 100644 index 00000000..9c01a869 --- /dev/null +++ b/picky-crypto-rustcrypto/src/lib.rs @@ -0,0 +1,88 @@ +//! RustCrypto capability entries for the picky cryptographic provider contract. +//! See the crate README for feature selection and supported algorithms. +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +#[cfg(feature = "aead")] +mod aead; +#[cfg(any(feature = "aes", feature = "legacy"))] +mod cbc; +#[cfg(feature = "curve25519")] +mod curve25519; +#[cfg(feature = "nist-ec")] +mod ec; +#[cfg(any(feature = "digest", feature = "legacy"))] +mod hash; +#[cfg(feature = "key-wrap")] +mod key_wrap; +#[cfg(feature = "digest")] +mod mac; +#[cfg(feature = "digest")] +mod pbkdf2; +#[cfg(any(feature = "aead", feature = "rsa", feature = "nist-ec", feature = "curve25519"))] +mod random; +#[cfg(feature = "legacy")] +mod rc4; +#[cfg(feature = "rsa")] +mod rsa; +#[cfg(any( + feature = "digest", + feature = "aes", + feature = "aead", + feature = "key-wrap", + feature = "rsa", + feature = "nist-ec", + feature = "curve25519", + feature = "legacy" +))] +mod util; + +/// Builds the provider with the capability families enabled by crate features. +pub fn provider() -> picky_crypto::CryptoProvider { + let builder = picky_crypto::CryptoProvider::builder(); + #[cfg(any(feature = "digest", feature = "legacy"))] + let builder = hash::entries(builder); + #[cfg(feature = "digest")] + let builder = pbkdf2::entries(mac::entries(builder)); + #[cfg(any(feature = "aes", feature = "legacy"))] + let builder = cbc::entries(builder); + #[cfg(feature = "aead")] + let builder = aead::entries(builder); + #[cfg(feature = "key-wrap")] + let builder = key_wrap::entries(builder); + #[cfg(feature = "rsa")] + let builder = rsa::entries(builder); + #[cfg(feature = "nist-ec")] + let builder = ec::entries(builder); + #[cfg(feature = "curve25519")] + let builder = curve25519::entries(builder); + #[cfg(feature = "legacy")] + let builder = rc4::entries(builder); + #[cfg(any(feature = "aead", feature = "rsa", feature = "nist-ec", feature = "curve25519"))] + let builder = builder.with(picky_crypto::Entry::SecureRandom(std::sync::Arc::new(random::Random))); + builder.build().expect("fixed, distinct RustCrypto entries") +} + +#[cfg(test)] +mod tests { + #[test] + fn feature_assembly() { + let provider = super::provider(); + let expected = 17 * usize::from(cfg!(feature = "digest")) + + 3 * usize::from(cfg!(feature = "aes")) + + 3 * usize::from(cfg!(feature = "aead")) + + 3 * usize::from(cfg!(feature = "key-wrap")) + + 15 * usize::from(cfg!(feature = "rsa")) + + 12 * usize::from(cfg!(feature = "nist-ec")) + + 5 * usize::from(cfg!(feature = "curve25519")) + + 5 * usize::from(cfg!(feature = "legacy")) + + usize::from(cfg!(any( + feature = "aead", + feature = "rsa", + feature = "nist-ec", + feature = "curve25519" + ))); + assert_eq!(provider.entries().count(), expected); + assert!(!provider.fips()); + } +} diff --git a/picky-crypto-rustcrypto/src/mac.rs b/picky-crypto-rustcrypto/src/mac.rs new file mode 100644 index 00000000..ac6145f1 --- /dev/null +++ b/picky-crypto-rustcrypto/src/mac.rs @@ -0,0 +1,74 @@ +use digest::{block_api::EagerHash, typenum::Unsigned}; +use hmac::{Hmac, KeyInit}; +use picky_crypto::{ + Algorithm, Entry, Error, Mac, MacAlgorithm, MacContext, MacOutput, Protection, ProviderBuilder, Zeroizing, +}; +use std::{marker::PhantomData, sync::Arc}; + +struct MacEntry(MacAlgorithm, PhantomData); +struct Context { + mac: Hmac, + length: crate::util::Length, +} + +impl Mac for MacEntry +where + Hmac: Send, +{ + fn algorithm(&self) -> MacAlgorithm { + self.0 + } + fn fips(&self) -> bool { + false + } + fn supports(&self, protection: Protection) -> bool { + crate::util::both(protection) + } + fn start(&self, key: &[u8], protection: Protection) -> Result, Error> { + if !self.supports(protection) { + return Err(Error::Unsupported(Algorithm::Mac(self.0))); + } + let limit = match self.0 { + MacAlgorithm::HmacSha1 | MacAlgorithm::HmacSha224 | MacAlgorithm::HmacSha256 => { + Some(u128::from(u64::MAX / 8)) + } + MacAlgorithm::HmacSha384 | MacAlgorithm::HmacSha512 => Some(u128::MAX / 8), + _ => None, + }; + if limit.is_some_and(|limit| key.len() as u128 > limit) { + return Err(Error::InvalidKey); + } + let limit = limit.map(|limit| limit - u128::from(D::BlockSize::U64)); + let mac = Hmac::::new_from_slice(key).map_err(|_| Error::InvalidKey)?; + Ok(Box::new(Context { + mac, + length: crate::util::Length::new(limit), + })) + } +} + +impl MacContext for Context +where + Hmac: Send, +{ + fn update(&mut self, data: &[u8]) -> Result<(), Error> { + self.length.update(data.len())?; + digest::Update::update(&mut self.mac, data); + Ok(()) + } + fn finish(self: Box) -> Result { + self.length.check()?; + let tag = Zeroizing::new(digest::FixedOutput::finalize_fixed(self.mac)); + Ok(MacOutput::new(crate::util::output(&tag)?.into_inner())) + } +} + +pub(crate) fn entries(builder: ProviderBuilder) -> ProviderBuilder { + macro_rules! add { + ($($algorithm:ident => $digest:ty),+ $(,)?) => { + builder $(.with(Entry::Mac(Arc::new(MacEntry::<$digest>(MacAlgorithm::$algorithm, PhantomData)))))+ + }; + } + add!(HmacSha1 => sha1::Sha1, HmacSha224 => sha2::Sha224, + HmacSha256 => sha2::Sha256, HmacSha384 => sha2::Sha384, HmacSha512 => sha2::Sha512) +} diff --git a/picky-crypto-rustcrypto/src/pbkdf2.rs b/picky-crypto-rustcrypto/src/pbkdf2.rs new file mode 100644 index 00000000..f32004a4 --- /dev/null +++ b/picky-crypto-rustcrypto/src/pbkdf2.rs @@ -0,0 +1,35 @@ +use digest::{block_api::EagerHash, typenum::Unsigned}; +use picky_crypto::{Algorithm, Entry, Error, OutputBytes, PasswordKdf, PasswordKdfAlgorithm, ProviderBuilder}; +use std::{marker::PhantomData, sync::Arc}; + +struct Pbkdf2(PasswordKdfAlgorithm, PhantomData); +impl PasswordKdf for Pbkdf2 { + fn algorithm(&self) -> PasswordKdfAlgorithm { + self.0 + } + fn fips(&self) -> bool { + false + } + fn derive(&self, password: &[u8], salt: &[u8], iterations: u32, output_len: usize) -> Result { + if iterations == 0 + || output_len == 0 + || output_len as u128 > u128::from(u32::MAX) * u128::from(D::OutputSize::U64) + { + return Err(Error::InvalidInput); + } + // Lengths beyond the must-support maximum may be refused, so an allocation failure is Unsupported. + let mut output = + crate::util::buffer(output_len).map_err(|_| Error::Unsupported(Algorithm::PasswordKdf(self.0)))?; + pbkdf2::pbkdf2::>(password, salt, iterations, &mut output).map_err(|_| Error::InvalidKey)?; + Ok(OutputBytes::new(output)) + } +} +pub(crate) fn entries(builder: ProviderBuilder) -> ProviderBuilder { + macro_rules! add { + ($($algorithm:ident => $digest:ty),+ $(,)?) => { + builder $(.with(Entry::PasswordKdf(Arc::new(Pbkdf2::<$digest>(PasswordKdfAlgorithm::$algorithm, PhantomData)))))+ + }; + } + add!(Pbkdf2HmacSha1 => sha1::Sha1, Pbkdf2HmacSha224 => sha2::Sha224, + Pbkdf2HmacSha256 => sha2::Sha256, Pbkdf2HmacSha384 => sha2::Sha384, Pbkdf2HmacSha512 => sha2::Sha512) +} diff --git a/picky-crypto-rustcrypto/src/random.rs b/picky-crypto-rustcrypto/src/random.rs new file mode 100644 index 00000000..caacedd6 --- /dev/null +++ b/picky-crypto-rustcrypto/src/random.rs @@ -0,0 +1,14 @@ +use picky_crypto::{Error, RandomAlgorithm, SecureRandom}; + +pub(crate) struct Random; +impl SecureRandom for Random { + fn algorithm(&self) -> RandomAlgorithm { + RandomAlgorithm::SecureRandom + } + fn fips(&self) -> bool { + false + } + fn fill(&self, dest: &mut [u8]) -> Result<(), Error> { + getrandom::fill(dest).map_err(|_| Error::ProviderFailure) + } +} diff --git a/picky-crypto-rustcrypto/src/rc4.rs b/picky-crypto-rustcrypto/src/rc4.rs new file mode 100644 index 00000000..0c34bae8 --- /dev/null +++ b/picky-crypto-rustcrypto/src/rc4.rs @@ -0,0 +1,47 @@ +use cipher::{KeyInit, StreamCipher as _}; +use picky_crypto::{ + Entry, Error, OutputBytes, ProviderBuilder, StreamCipher, StreamCipherAlgorithm, StreamCipherContext, +}; +use std::sync::Arc; + +struct Rc4; +struct Context { + cipher: rc4::Rc4, + poisoned: bool, +} +impl StreamCipher for Rc4 { + fn algorithm(&self) -> StreamCipherAlgorithm { + StreamCipherAlgorithm::Rc4 + } + fn fips(&self) -> bool { + false + } + fn start(&self, key: &[u8]) -> Result, Error> { + let cipher = rc4::Rc4::new_from_slice(key).map_err(|_| Error::InvalidKey)?; + Ok(Box::new(Context { + cipher, + poisoned: false, + })) + } +} +impl StreamCipherContext for Context { + fn apply(&mut self, data: &[u8]) -> Result { + if self.poisoned { + return Err(Error::ProviderFailure); + } + let result = (|| { + let mut output = crate::util::output(data)?.into_inner(); + self.cipher + .try_apply_keystream(&mut output) + .map_err(|_| Error::InvalidInput)?; + Ok(OutputBytes::new(output)) + })(); + if result.is_err() { + self.poisoned = true; + } + result + } +} +pub(crate) fn entries(builder: ProviderBuilder) -> ProviderBuilder { + builder.with(Entry::StreamCipher(Arc::new(Rc4))) +} diff --git a/picky-crypto-rustcrypto/src/rsa.rs b/picky-crypto-rustcrypto/src/rsa.rs new file mode 100644 index 00000000..666dbaeb --- /dev/null +++ b/picky-crypto-rustcrypto/src/rsa.rs @@ -0,0 +1,425 @@ +use picky_crypto::{ + Algorithm, AsymmetricEncryptionAlgorithm, AsymmetricEncryptor, Entry, Error, KeyGenerationAlgorithm, KeyGenerator, + KeyOperation, KeyType, OutputBytes, PrivateKey, PrivateKeyLoader, PrivateKeyMaterial, ProviderBuilder, PublicKey, + SignatureAlgorithm, SignatureVerifier, Zeroizing, +}; +use rsa::{ + BoxedUint, RsaPrivateKey, RsaPublicKey, + pkcs1::EncodeRsaPublicKey, + pkcs8::{EncodePrivateKey, PrivateKeyInfoRef, Version, der::Decode}, + signature::Verifier, + traits::{PaddingScheme, PublicKeyParts, SignatureScheme}, +}; +use std::sync::Arc; + +struct Verify(SignatureAlgorithm); +struct Encrypt(AsymmetricEncryptionAlgorithm); +struct Loader; +struct Generator(KeyGenerationAlgorithm); +struct Key(RsaPrivateKey); + +fn precision(bytes: &[u8]) -> Result { + u32::try_from(bytes.len()) + .ok() + .and_then(|len| len.checked_mul(8)) + .ok_or(Error::InvalidKey) +} + +fn uint(bytes: &[u8], bits: u32) -> Result { + BoxedUint::from_be_slice(bytes, bits).map_err(|_| Error::InvalidKey) +} + +fn load_public_key(der: &[u8], algorithm: Algorithm) -> Result { + let parsed = rsa::pkcs1::RsaPublicKey::from_der(der).map_err(|_| Error::InvalidKey)?; + let n = uint(parsed.modulus.as_bytes(), precision(parsed.modulus.as_bytes())?)?; + let e = uint( + parsed.public_exponent.as_bytes(), + precision(parsed.public_exponent.as_bytes())?, + )?; + // Classification follows the library's check order: modulus size, structure, then exponent bounds + // (rsa 0.10.0-rc.18, src\key.rs:228–234,714–745). + RsaPublicKey::new(n, e).map_err(|error| match error { + rsa::Error::ModulusTooLarge | rsa::Error::PublicExponentTooLarge => Error::Unsupported(algorithm), + _ => Error::InvalidKey, + }) +} + +macro_rules! with_digest { + ($algorithm:expr, $call:ident $(, $argument:expr)*) => { + match $algorithm { + SignatureAlgorithm::RsaPkcs1v15Md5 => $call::($($argument),*), + SignatureAlgorithm::RsaPkcs1v15Sha1 => $call::($($argument),*), + SignatureAlgorithm::RsaPkcs1v15Sha224 => $call::($($argument),*), + SignatureAlgorithm::RsaPkcs1v15Sha256 => $call::($($argument),*), + SignatureAlgorithm::RsaPkcs1v15Sha384 => $call::($($argument),*), + SignatureAlgorithm::RsaPkcs1v15Sha512 => $call::($($argument),*), + SignatureAlgorithm::RsaPkcs1v15Sha3_384 => $call::($($argument),*), + SignatureAlgorithm::RsaPkcs1v15Sha3_512 => $call::($($argument),*), + _ => Err(Error::Unsupported(Algorithm::Signature($algorithm))), + } + }; +} +fn verify( + key: RsaPublicKey, + message: &[u8], + signature: &[u8], +) -> Result<(), Error> { + if signature.len() != key.size() { + return Err(Error::VerificationFailed); + } + let signature = rsa::pkcs1v15::Signature::try_from(signature).map_err(|_| Error::VerificationFailed)?; + rsa::pkcs1v15::VerifyingKey::::new(key) + .verify(message, &signature) + .map_err(|_| Error::VerificationFailed) +} +impl SignatureVerifier for Verify { + fn algorithm(&self) -> SignatureAlgorithm { + self.0 + } + fn fips(&self) -> bool { + false + } + fn verify(&self, public_key: PublicKey<'_>, message: &[u8], signature: &[u8]) -> Result<(), Error> { + let key = load_public_key(public_key.0, Algorithm::Signature(self.0))?; + with_digest!(self.0, verify, key, message, signature) + } +} + +fn encryption_supported(algorithm: AsymmetricEncryptionAlgorithm) -> bool { + matches!( + algorithm, + AsymmetricEncryptionAlgorithm::RsaPkcs1v15 + | AsymmetricEncryptionAlgorithm::RsaOaepSha1 + | AsymmetricEncryptionAlgorithm::RsaOaepSha256 + ) +} +fn error(error: rsa::Error) -> Error { + // Decryption re-encrypts its result and reports a mismatch as Internal, mapped to InvalidKey + // (rsa 0.10.0-rc.18, src\algorithms\rsa.rs:156–172; src\pkcs1v15.rs:180; src\oaep.rs:279). + match error { + rsa::Error::Rng => Error::ProviderFailure, + rsa::Error::MessageTooLong => Error::InvalidInput, + rsa::Error::Decryption | rsa::Error::Verification => Error::VerificationFailed, + _ => Error::InvalidKey, + } +} +fn encrypt(key: &RsaPublicKey, plaintext: &[u8], padding: P) -> Result { + // Native ciphertext output is modulus-sized (rsa 0.10.0-rc.18, src\pkcs1v15.rs:159; src\oaep.rs:218). + padding + .encrypt(&mut getrandom::SysRng, key, plaintext) + .map(|output| OutputBytes::new(Zeroizing::new(output))) + .map_err(error) +} +impl AsymmetricEncryptor for Encrypt { + fn algorithm(&self) -> AsymmetricEncryptionAlgorithm { + self.0 + } + fn fips(&self) -> bool { + false + } + fn encrypt(&self, public_key: PublicKey<'_>, plaintext: &[u8]) -> Result { + let key = load_public_key(public_key.0, Algorithm::AsymmetricEncryption(self.0))?; + match self.0 { + AsymmetricEncryptionAlgorithm::RsaPkcs1v15 => encrypt(&key, plaintext, rsa::Pkcs1v15Encrypt), + AsymmetricEncryptionAlgorithm::RsaOaepSha1 => encrypt(&key, plaintext, rsa::Oaep::::new()), + AsymmetricEncryptionAlgorithm::RsaOaepSha256 => encrypt(&key, plaintext, rsa::Oaep::::new()), + _ => Err(Error::Unsupported(Algorithm::AsymmetricEncryption(self.0))), + } + } +} +impl PrivateKeyLoader for Loader { + fn key_type(&self) -> KeyType { + KeyType::Rsa + } + fn fips(&self) -> bool { + false + } + fn load(&self, material: PrivateKeyMaterial<'_>) -> Result, Error> { + let PrivateKeyMaterial::Pkcs8(der) = material else { + return Err(Error::InvalidKey); + }; + // The library's PKCS#1 decoder multiplies the public-exponent length by eight in u32 + // (rsa 0.10.0-rc.18, src\encoding.rs:55–57). + if der.len() as u128 > u128::from(u32::MAX / 8) { + return Err(Error::InvalidKey); + } + let info = PrivateKeyInfoRef::from_der(der).map_err(|_| Error::InvalidKey)?; + if info.version() != Version::V1 { + return Err(Error::InvalidKey); + } + info.algorithm + .assert_algorithm_oid(rsa::pkcs1::ALGORITHM_OID) + .map_err(|_| Error::InvalidKey)?; + if info.algorithm.parameters_any().map_err(|_| Error::InvalidKey)? != rsa::pkcs8::der::asn1::Null.into() { + return Err(Error::InvalidKey); + } + let parsed = rsa::pkcs1::RsaPrivateKey::from_der(info.private_key.as_bytes()).map_err(|_| Error::InvalidKey)?; + // The library's public-key limits apply before decoding: it loads private keys of any modulus size + // (rsa 0.10.0-rc.18, src\key.rs:706–710) and reports an oversized exponent only as a malformed key + // (src\encoding.rs:66; src\key.rs:723–729). `as_bytes` strips the sign octet, so 8192 bits is 1024 bytes + // (der 0.8.2, src\asn1\integer\uint.rs:351–365,406–416). + let exponent = parsed.public_exponent.as_bytes(); + if parsed.modulus.as_bytes().len() > RsaPublicKey::MAX_SIZE / 8 + || uint(exponent, precision(exponent)?)? > BoxedUint::from(RsaPublicKey::MAX_PUB_EXPONENT) + { + return Err(Error::Unsupported(Algorithm::PrivateKeyLoading(KeyType::Rsa))); + } + let key = RsaPrivateKey::try_from(info).map_err(|_| Error::InvalidKey)?; + Ok(Box::new(Key(key))) + } +} +impl KeyGenerator for Generator { + fn algorithm(&self) -> KeyGenerationAlgorithm { + self.0 + } + fn fips(&self) -> bool { + false + } + fn generate(&self) -> Result { + use rsa::rand_core::SeedableRng; + let bits = match self.0 { + KeyGenerationAlgorithm::Rsa2048 => 2048, + KeyGenerationAlgorithm::Rsa3072 => 3072, + KeyGenerationAlgorithm::Rsa4096 => 4096, + _ => return Err(Error::Unsupported(Algorithm::KeyGeneration(self.0))), + }; + let mut seed = Zeroizing::new([0u8; 32]); + getrandom::fill(&mut *seed).map_err(|_| Error::ProviderFailure)?; + // Infallible seeded RNG that wipes its buffer and state on drop (chacha20 0.10.2, src\rng.rs:94–98; + // src\lib.rs:306–310). + let mut rng = chacha20::ChaCha20Rng::from_seed(*seed); + let key = RsaPrivateKey::new_with_exp(&mut rng, bits, BoxedUint::from(65537u64)) + .map_err(|_| Error::ProviderFailure)?; + let document = key.to_pkcs8_der().map_err(|_| Error::ProviderFailure)?; + crate::util::output(document.as_bytes()) + } +} +fn signing_error(error: rsa::Error, algorithm: SignatureAlgorithm) -> Error { + // An undersized modulus is MessageTooLong (rsa 0.10.0-rc.18, src\algorithms\pkcs1v15.rs:120–125). + // Signing checks its result like decryption and reports a mismatch as Internal, mapped to InvalidKey + // (src\algorithms\rsa.rs:156–172; src\pkcs1v15.rs:209). + match error { + rsa::Error::MessageTooLong => Error::Unsupported(Algorithm::Signature(algorithm)), + rsa::Error::Rng => Error::ProviderFailure, + _ => Error::InvalidKey, + } +} + +fn sign( + key: &RsaPrivateKey, + message: &[u8], + algorithm: SignatureAlgorithm, +) -> Result { + let digest = Zeroizing::new(D::digest(message)); + // The scheme blinds with a fallible generator, unlike `sign_with_rng`, and pads to size(), not whole limbs + // (rsa 0.10.0-rc.18, src\key.rs:662; src\traits\padding.rs:35–40; src\pkcs1v15.rs:200–209). + rsa::Pkcs1v15Sign::new::() + .sign(Some(&mut getrandom::SysRng), key, &digest) + .map(|signature| OutputBytes::new(Zeroizing::new(signature))) + .map_err(|error| signing_error(error, algorithm)) +} +fn decrypt(key: &RsaPrivateKey, ciphertext: &[u8], padding: P) -> Result { + padding + .decrypt(Some(&mut getrandom::SysRng), key, ciphertext) + .map(|output| OutputBytes::new(Zeroizing::new(output))) + .map_err(error) +} +impl PrivateKey for Key { + fn key_type(&self) -> KeyType { + KeyType::Rsa + } + fn key_size_bits(&self) -> usize { + self.0.n().bits_vartime() as usize + } + fn fips(&self) -> bool { + false + } + fn supports(&self, operation: KeyOperation) -> bool { + match operation { + KeyOperation::Sign(algorithm) => matches!( + algorithm, + SignatureAlgorithm::RsaPkcs1v15Md5 + | SignatureAlgorithm::RsaPkcs1v15Sha1 + | SignatureAlgorithm::RsaPkcs1v15Sha224 + | SignatureAlgorithm::RsaPkcs1v15Sha256 + | SignatureAlgorithm::RsaPkcs1v15Sha384 + | SignatureAlgorithm::RsaPkcs1v15Sha512 + | SignatureAlgorithm::RsaPkcs1v15Sha3_384 + | SignatureAlgorithm::RsaPkcs1v15Sha3_512 + ), + KeyOperation::Decrypt(algorithm) => encryption_supported(algorithm), + KeyOperation::PublicKey => true, + _ => false, + } + } + fn sign(&self, algorithm: SignatureAlgorithm, message: &[u8]) -> Result { + with_digest!(algorithm, sign, &self.0, message, algorithm) + } + fn decrypt(&self, algorithm: AsymmetricEncryptionAlgorithm, ciphertext: &[u8]) -> Result { + if !encryption_supported(algorithm) { + return Err(Error::Unsupported(Algorithm::AsymmetricEncryption(algorithm))); + } + if ciphertext.len() != self.0.size() { + return Err(Error::InvalidInput); + } + match algorithm { + AsymmetricEncryptionAlgorithm::RsaPkcs1v15 => decrypt(&self.0, ciphertext, rsa::Pkcs1v15Encrypt), + AsymmetricEncryptionAlgorithm::RsaOaepSha1 => decrypt(&self.0, ciphertext, rsa::Oaep::::new()), + AsymmetricEncryptionAlgorithm::RsaOaepSha256 => { + decrypt(&self.0, ciphertext, rsa::Oaep::::new()) + } + _ => Err(Error::Unsupported(Algorithm::AsymmetricEncryption(algorithm))), + } + } + fn public_key(&self) -> Result { + let public = self.0.as_public_key().to_pkcs1_der().map_err(|_| Error::InvalidKey)?; + crate::util::output(public.as_bytes()) + } +} +pub(crate) fn entries(builder: ProviderBuilder) -> ProviderBuilder { + let builder = [ + SignatureAlgorithm::RsaPkcs1v15Md5, + SignatureAlgorithm::RsaPkcs1v15Sha1, + SignatureAlgorithm::RsaPkcs1v15Sha224, + SignatureAlgorithm::RsaPkcs1v15Sha256, + SignatureAlgorithm::RsaPkcs1v15Sha384, + SignatureAlgorithm::RsaPkcs1v15Sha512, + SignatureAlgorithm::RsaPkcs1v15Sha3_384, + SignatureAlgorithm::RsaPkcs1v15Sha3_512, + ] + .into_iter() + .fold(builder, |builder, algorithm| { + builder.with(Entry::SignatureVerifier(Arc::new(Verify(algorithm)))) + }); + let builder = [ + AsymmetricEncryptionAlgorithm::RsaPkcs1v15, + AsymmetricEncryptionAlgorithm::RsaOaepSha1, + AsymmetricEncryptionAlgorithm::RsaOaepSha256, + ] + .into_iter() + .fold(builder, |builder, algorithm| { + builder.with(Entry::AsymmetricEncryptor(Arc::new(Encrypt(algorithm)))) + }); + let builder = builder.with(Entry::PrivateKeyLoader(Arc::new(Loader))); + [ + KeyGenerationAlgorithm::Rsa2048, + KeyGenerationAlgorithm::Rsa3072, + KeyGenerationAlgorithm::Rsa4096, + ] + .into_iter() + .fold(builder, |builder, algorithm| { + builder.with(Entry::KeyGenerator(Arc::new(Generator(algorithm)))) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use rsa::pkcs8::der::{Encode, asn1::UintRef}; + + #[test] + fn rsa_limits_preserve_operation_identity() { + let modulus = [1u8; 1025]; + let too_large_exponent = [2u8, 0, 0, 0, 1]; + let mut even_modulus = [1u8; 256]; + even_modulus[255] = 2; + let signature = SignatureAlgorithm::RsaPkcs1v15Sha256; + let encryption = AsymmetricEncryptionAlgorithm::RsaOaepSha256; + for (n, e, unsupported) in [ + (&modulus[..], &[1, 0, 1][..], true), + (&modulus[..256], &too_large_exponent[..], true), + (&even_modulus[..], &[1, 0, 1][..], false), + ] { + let der = rsa::pkcs1::RsaPublicKey { + modulus: UintRef::new(n).unwrap(), + public_exponent: UintRef::new(e).unwrap(), + } + .to_der() + .unwrap(); + let expected = |algorithm| { + if unsupported { + Error::Unsupported(algorithm) + } else { + Error::InvalidKey + } + }; + assert_eq!( + Verify(signature).verify(PublicKey(&der), b"", b""), + Err(expected(Algorithm::Signature(signature))) + ); + assert_eq!( + Encrypt(encryption).encrypt(PublicKey(&der), b"").err(), + Some(expected(Algorithm::AsymmetricEncryption(encryption))) + ); + } + } + + #[test] + fn private_modulus_above_public_limit_is_unsupported() { + use rsa::pkcs8::der::asn1::OctetStringRef; + let one = UintRef::new(&[1]).unwrap(); + let modulus = [0xffu8; 1025]; + for (len, expected) in [ + (1025, Error::Unsupported(Algorithm::PrivateKeyLoading(KeyType::Rsa))), + (1024, Error::InvalidKey), + ] { + let pkcs1 = rsa::pkcs1::RsaPrivateKey { + modulus: UintRef::new(&modulus[..len]).unwrap(), + public_exponent: one, + private_exponent: one, + prime1: one, + prime2: one, + exponent1: one, + exponent2: one, + coefficient: one, + other_prime_infos: None, + } + .to_der() + .unwrap(); + let info = PrivateKeyInfoRef::new(rsa::pkcs1::ALGORITHM_ID, OctetStringRef::new(&pkcs1).unwrap()); + let der = info.to_der().unwrap(); + assert_eq!(Loader.load(PrivateKeyMaterial::Pkcs8(&der)).err(), Some(expected)); + } + } + + // Signatures must be padded to the modulus width, not to whole limbs (Signature::to_bytes()). + #[test] + fn rsa_outputs_use_modulus_width() { + use rsa::rand_core::SeedableRng; + let mut rng = chacha20::ChaCha20Rng::from_seed([7u8; 32]); + let document = RsaPrivateKey::new(&mut rng, 1040).unwrap().to_pkcs8_der().unwrap(); + let key = Loader.load(PrivateKeyMaterial::Pkcs8(document.as_bytes())).unwrap(); + let algorithm = SignatureAlgorithm::RsaPkcs1v15Sha256; + let signature = key.sign(algorithm, b"message").unwrap(); + assert_eq!(signature.len(), 130); + let public = key.public_key().unwrap(); + Verify(algorithm) + .verify(PublicKey(&public), b"message", &signature) + .unwrap(); + } + + #[test] + fn small_modulus_and_large_exponent_are_unsupported() { + use rsa::{rand_core::SeedableRng, traits::PrivateKeyParts}; + let seed = Zeroizing::new([42u8; 32]); + let mut rng = chacha20::ChaCha20Rng::from_seed(*seed); + let key = RsaPrivateKey::new_unchecked(&mut rng, 128).unwrap(); + let swapped = RsaPrivateKey::from_components_with_large_exponent( + key.n().as_ref().clone(), + key.d().clone(), + key.e().clone(), + key.primes().to_vec(), + ) + .unwrap(); + let document = swapped.to_pkcs8_der().unwrap(); + assert!(matches!( + Loader.load(PrivateKeyMaterial::Pkcs8(document.as_bytes())), + Err(Error::Unsupported(Algorithm::PrivateKeyLoading(KeyType::Rsa))) + )); + let algorithm = SignatureAlgorithm::RsaPkcs1v15Sha512; + assert!(matches!( + Key(key).sign(algorithm, b""), + Err(Error::Unsupported(Algorithm::Signature(a))) if a == algorithm + )); + } +} diff --git a/picky-crypto-rustcrypto/src/util.rs b/picky-crypto-rustcrypto/src/util.rs new file mode 100644 index 00000000..c7c16e5f --- /dev/null +++ b/picky-crypto-rustcrypto/src/util.rs @@ -0,0 +1,89 @@ +use picky_crypto::{Error, Zeroizing}; + +pub(crate) fn buffer(len: usize) -> Result>, Error> { + let mut output = Zeroizing::new(Vec::new()); + output.try_reserve_exact(len).map_err(|_| Error::ProviderFailure)?; + output.resize(len, 0); + Ok(output) +} + +#[cfg(any( + feature = "digest", + feature = "aes", + feature = "aead", + feature = "rsa", + feature = "nist-ec", + feature = "curve25519", + feature = "legacy" +))] +pub(crate) fn output(data: &[u8]) -> Result { + let mut output = buffer(data.len())?; + output.copy_from_slice(data); + Ok(picky_crypto::OutputBytes::new(output)) +} + +#[cfg(any( + feature = "digest", + feature = "aes", + feature = "aead", + feature = "key-wrap", + feature = "legacy" +))] +pub(crate) fn both(protection: picky_crypto::Protection) -> bool { + matches!( + protection, + picky_crypto::Protection::Apply | picky_crypto::Protection::Process + ) +} + +#[cfg(any(feature = "digest", feature = "legacy"))] +pub(crate) struct Length { + remaining: Option, + poisoned: bool, +} + +#[cfg(any(feature = "digest", feature = "legacy"))] +impl Length { + pub(crate) fn new(remaining: Option) -> Self { + Self { + remaining, + poisoned: false, + } + } + + pub(crate) fn update(&mut self, len: usize) -> Result<(), Error> { + self.check()?; + if let Some(remaining) = self.remaining { + let next = remaining.checked_sub(len as u128); + if next.is_none() { + self.poisoned = true; + return Err(Error::InvalidInput); + } + self.remaining = next; + } + Ok(()) + } + + pub(crate) fn check(&self) -> Result<(), Error> { + if self.poisoned { + Err(Error::ProviderFailure) + } else { + Ok(()) + } + } +} + +#[cfg(all(test, any(feature = "digest", feature = "legacy")))] +mod tests { + use super::*; + + #[test] + fn length_limit_poisoning() { + let mut length = Length::new(Some(2)); + assert_eq!(length.update(2), Ok(())); + assert_eq!(length.update(1), Err(Error::InvalidInput)); + assert_eq!(length.update(0), Err(Error::ProviderFailure)); + assert_eq!(length.check(), Err(Error::ProviderFailure)); + assert_eq!(Length::new(Some(u128::MAX / 8)).update(usize::MAX), Ok(())); + } +} diff --git a/picky-crypto/CONTRACT.md b/picky-crypto/CONTRACT.md index 324fe7ad..6cb2da34 100644 --- a/picky-crypto/CONTRACT.md +++ b/picky-crypto/CONTRACT.md @@ -143,6 +143,7 @@ The error set is closed: only the contract defines variants, never a backend, an [`install_default`] returns the rejected provider instead of an error value. When both [`Error::InvalidKey`] and [`Error::InvalidInput`] could apply, report [`Error::InvalidKey`]. +Library validation failures map to [`Error::InvalidKey`], except when the library only rejects a well-formed key for its size or parameters, which maps to [`Error::Unsupported`] (rule 4). [`Error::ProviderFailure`] includes "PIN required", which callers therefore cannot distinguish from other device failures; this is a known limit of the closed set, to revisit if a consumer needs to prompt for a PIN. Some libraries report a single opaque failure for verification; a backend on such a library may report [`Error::VerificationFailed`] for a public key it cannot use, including an RSA modulus outside its range. Conformance tests therefore expect [`Error::InvalidKey`] for a malformed public key, and accept either the result expected for a key in the must-support set or [`Error::Unsupported`] for a well-formed public key outside it; for a backend on such a library they also accept [`Error::VerificationFailed`] in both cases. @@ -549,7 +550,7 @@ Cryptographically invalid inputs still fail with the specified error (for exampl Conformance tests check every [`KeyOperation`] on unwrapped keys. When [`PrivateKey::supports`] is false, the operation returns [`Error::Unsupported`]. When [`PrivateKey::supports`] is true, valid known-answer and round-trip cases succeed, no must-support input returns [`Error::Unsupported`], and invalid inputs return the specified error. -RSA keys with inconsistent components are checked as section 12 states. +RSA keys with inconsistent or non-distinct components are checked as section 12 states. [`PrivateKey::key_size_bits`] reports: @@ -573,11 +574,12 @@ RSASSA-PKCS1-v1_5 signatures always encode NULL DigestInfo parameters (section 6 RSA private-key results are checked: every signature or plaintext is the correct result under the key's public components (`n`, `e`), or the operation fails. A backend never outputs a signature or plaintext computed from inconsistent key components, because a single faulty CRT signature reveals a prime factor of `n`. -Handling an RSA private key with inconsistent components (wrong `dP`, `dQ`, `qInv` or `d`) is implementation-defined. -It may be rejected at loading with [`Error::InvalidKey`], rejected at the first private-key operation with [`Error::InvalidKey`], or used with components the library recomputes from `n`, `e`, `d`, `p` and `q`. -Every one of these outcomes satisfies the property above. +Handling an RSA private key with inconsistent or non-distinct components (wrong `dP`, `dQ`, `qInv` or `d`, or `p` = `q`) is implementation-defined. +The outcome may differ per call: each private-key call either returns [`Error::InvalidKey`] or behaves as specified for the key's public components (`n`, `e`), producing a signature or plaintext correct under them or the error specified for that input, such as [`Error::VerificationFailed`] when the correctly decrypted value has invalid padding. +For example, a backend may reject the key at loading with [`Error::InvalidKey`], reject it at the first private-key operation with [`Error::InvalidKey`], use CRT components the library recomputes from `n`, `e`, `d`, `p` and `q` when the primes are distinct, or check each result against (`n`, `e`). +Every one of these outcomes satisfies the checked-result property above. This is an exception to rule 4, which would otherwise require rejection at loading. -An inconsistency detected during [`PrivateKey::sign`] or [`PrivateKey::decrypt`] returns [`Error::InvalidKey`], never the padding error [`Error::VerificationFailed`]. +An inconsistency detected during [`PrivateKey::sign`] or [`PrivateKey::decrypt`] returns [`Error::InvalidKey`], never the padding error [`Error::VerificationFailed`], which applies only to a decrypted value correct under (`n`, `e`). Conformance tests check these keys only as section 12 states. Derivability: @@ -810,7 +812,7 @@ For each of them, conformance tests assert that nothing panics and that the resu - RSA PKCS#1 v1.5 verification of a DigestInfo with absent parameters (section 6.9): success or [`Error::VerificationFailed`], the result section 6.9 gives for a signature that does not verify; - loading a PKCS#8 document that carries `attributes` (section 5.2): success or [`Error::InvalidKey`]; - a 3DES key whose component keys are not pairwise distinct or are DES weak or semi-weak keys (section 6.5): success or [`Error::InvalidKey`]; -- handling of an RSA private key with inconsistent components (section 7): loading returns [`Error::InvalidKey`], or every private-key operation either returns [`Error::InvalidKey`] or produces a result that verifies under (`n`, `e`). +- handling of an RSA private key with inconsistent or non-distinct components (section 7): loading returns [`Error::InvalidKey`], or every private-key operation either returns [`Error::InvalidKey`], produces a result that verifies under (`n`, `e`), or returns the error section 7 specifies for that input. For decryption, the ciphertext comes from the first source that produces one for the algorithm: the base key's published ciphertexts, then the conformance runner's reference encryptor from another backend in the same build, then the provider's own encryptor. The expected plaintext is the published one for a published ciphertext, and the plaintext the suite encrypted for the other two sources. The check is never skipped: it fails if no source produces a ciphertext. @@ -823,6 +825,8 @@ The inputs for that last property are derived from published keys, because the k - round-trip control: splitting a base key into its fields and reassembling them unchanged must reproduce the cited key's exact bytes, otherwise the test fails; - positive control: the unmodified base key loads and produces a result that verifies on the backend under test. +No known published key has non-distinct components (`p` = `q`, so `n` = `p`²), and these derivations cannot produce one, so that case has no conformance vector. + `picky-crypto`'s tests check composition (section 8.1) for [`Mac`], [`Cipher`], [`Aead`] and [`KeyWrap`] against mock providers, over every combination of supported protections and FIPS reports: a fallback fills in a protection the primary does not support, the primary serves a protection both members support, and the composite entry's FIPS report and the composed provider's [`CryptoProvider::fips`] follow section 8.1. For [`Mac`], each context starts on the member serving the requested protection. From ad1b4cfbbef7e3c2e97e973cc303e7c4e2f0c35f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sun, 11 Oct 2026 01:56:55 +0900 Subject: [PATCH 02/30] fix(picky-crypto): draw ECDSA nonces from the OS generator ECDSA signing now mixes operating-system randomness into the RFC 6979 nonce, as the contract requires for ECDSA nonces. PBKDF2 reports an output allocation failure as unsupported only above the must-support output length. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-rustcrypto/README.md | 3 ++- picky-crypto-rustcrypto/src/ec.rs | 8 ++++++-- picky-crypto-rustcrypto/src/pbkdf2.rs | 13 ++++++++++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/picky-crypto-rustcrypto/README.md b/picky-crypto-rustcrypto/README.md index 09eb6c51..cd0aa300 100644 --- a/picky-crypto-rustcrypto/README.md +++ b/picky-crypto-rustcrypto/README.md @@ -5,7 +5,7 @@ Call `picky_crypto_rustcrypto::provider()` to build a provider with the enabled Nothing is enabled by default; `all` enables every family below. Every entry, loader and private key reports `fips() == false`. -Parsing and encoding formats needs no provider; only code that performs cryptographic operations through `picky-crypto` does. +Parsing and encoding formats need no provider; only code that performs cryptographic operations through `picky-crypto` does. ## Features and the library @@ -37,6 +37,7 @@ Enabling ed25519-dalek's `legacy_compatibility` feature anywhere in a build weak Key-based KDFs and finite-field Diffie-Hellman are not provided; they come from other providers. RSA key generation draws a 256-bit seed from the operating-system generator and expands it with ChaCha20, so a generator failure is reported instead of panicking. RSA signing and decryption blind the private-key operation with the operating-system generator. +ECDSA signing mixes operating-system randomness into the RFC 6979 nonce as additional data. RSA public keys beyond the rsa crate's limits (a modulus above 8192 bits or a public exponent above 2^33 − 1) are unsupported. Private-key loading applies the same limits, although the rsa crate loads private keys of any modulus size. diff --git a/picky-crypto-rustcrypto/src/ec.rs b/picky-crypto-rustcrypto/src/ec.rs index 73226639..c58a46d4 100644 --- a/picky-crypto-rustcrypto/src/ec.rs +++ b/picky-crypto-rustcrypto/src/ec.rs @@ -20,7 +20,7 @@ macro_rules! curve { SecretKey, ecdh, ecdsa::{ Signature, SigningKey, VerifyingKey, - signature::{Signer, Verifier}, + signature::{RandomizedSigner, Verifier}, }, elliptic_curve::{Generate, sec1::ToSec1Point}, pkcs8::{EncodePrivateKey, PrivateKeyInfoRef, Version, der::Decode}, @@ -102,7 +102,11 @@ macro_rules! curve { return Err(Error::Unsupported(Algorithm::Signature(algorithm))); } let signing = SigningKey::from(&self.0); - let signature: Signature = signing.try_sign(message).map_err(|_| Error::InvalidKey)?; + // Hedged RFC 6979 nonce: random bytes enter as additional data, and an RNG failure is + // the only error on this path (ecdsa 0.17.0, src\signing.rs:209–232, 242–265). + let signature: Signature = signing + .try_sign_with_rng(&mut getrandom::SysRng, message) + .map_err(|_| Error::ProviderFailure)?; let bytes = Zeroizing::new(signature.to_bytes()); crate::util::output(&bytes) } diff --git a/picky-crypto-rustcrypto/src/pbkdf2.rs b/picky-crypto-rustcrypto/src/pbkdf2.rs index f32004a4..f5e40b8d 100644 --- a/picky-crypto-rustcrypto/src/pbkdf2.rs +++ b/picky-crypto-rustcrypto/src/pbkdf2.rs @@ -2,6 +2,8 @@ use digest::{block_api::EagerHash, typenum::Unsigned}; use picky_crypto::{Algorithm, Entry, Error, OutputBytes, PasswordKdf, PasswordKdfAlgorithm, ProviderBuilder}; use std::{marker::PhantomData, sync::Arc}; +const MUST_SUPPORT_MAX_OUTPUT_LEN: usize = 1024; + struct Pbkdf2(PasswordKdfAlgorithm, PhantomData); impl PasswordKdf for Pbkdf2 { fn algorithm(&self) -> PasswordKdfAlgorithm { @@ -17,9 +19,14 @@ impl PasswordKdf for Pbkdf2 { { return Err(Error::InvalidInput); } - // Lengths beyond the must-support maximum may be refused, so an allocation failure is Unsupported. - let mut output = - crate::util::buffer(output_len).map_err(|_| Error::Unsupported(Algorithm::PasswordKdf(self.0)))?; + // Only lengths beyond the must-support maximum may be refused as Unsupported. + let mut output = crate::util::buffer(output_len).map_err(|_| { + if output_len > MUST_SUPPORT_MAX_OUTPUT_LEN { + Error::Unsupported(Algorithm::PasswordKdf(self.0)) + } else { + Error::ProviderFailure + } + })?; pbkdf2::pbkdf2::>(password, salt, iterations, &mut output).map_err(|_| Error::InvalidKey)?; Ok(OutputBytes::new(output)) } From e12286fef4ffcf2dc41f2802f64c728b4bd7e366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Sun, 11 Oct 2026 02:09:36 +0900 Subject: [PATCH 03/30] docs(picky-crypto): limit the validation error rule to keys The section 4 rule on library validation failures covers key validation only; non-key inputs keep the errors the section 4 table assigns. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto/CONTRACT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/picky-crypto/CONTRACT.md b/picky-crypto/CONTRACT.md index 6cb2da34..a32e14b7 100644 --- a/picky-crypto/CONTRACT.md +++ b/picky-crypto/CONTRACT.md @@ -143,7 +143,7 @@ The error set is closed: only the contract defines variants, never a backend, an [`install_default`] returns the rejected provider instead of an error value. When both [`Error::InvalidKey`] and [`Error::InvalidInput`] could apply, report [`Error::InvalidKey`]. -Library validation failures map to [`Error::InvalidKey`], except when the library only rejects a well-formed key for its size or parameters, which maps to [`Error::Unsupported`] (rule 4). +Library key-validation failures map to [`Error::InvalidKey`], except when the library only rejects a well-formed key for its size or parameters, which maps to [`Error::Unsupported`] (rule 4). [`Error::ProviderFailure`] includes "PIN required", which callers therefore cannot distinguish from other device failures; this is a known limit of the closed set, to revisit if a consumer needs to prompt for a PIN. Some libraries report a single opaque failure for verification; a backend on such a library may report [`Error::VerificationFailed`] for a public key it cannot use, including an RSA modulus outside its range. Conformance tests therefore expect [`Error::InvalidKey`] for a malformed public key, and accept either the result expected for a key in the must-support set or [`Error::Unsupported`] for a well-formed public key outside it; for a backend on such a library they also accept [`Error::VerificationFailed`] in both cases. From 7d72ffc1acf5c2ad716bf93a3a7cc5fdd39fa9c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 13 Oct 2026 12:26:46 +0900 Subject: [PATCH 04/30] docs(picky-crypto): state exact outcomes for inconsistent RSA keys Conformance outcome sets for RSA private keys with inconsistent or non-distinct components are exact per test input: a signature verifies under (n, e) or the call returns InvalidKey, and decrypting a ciphertext with valid padding returns the expected plaintext or InvalidKey, never VerificationFailed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto/CONTRACT.md | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/picky-crypto/CONTRACT.md b/picky-crypto/CONTRACT.md index a32e14b7..9749f401 100644 --- a/picky-crypto/CONTRACT.md +++ b/picky-crypto/CONTRACT.md @@ -575,11 +575,11 @@ RSASSA-PKCS1-v1_5 signatures always encode NULL DigestInfo parameters (section 6 RSA private-key results are checked: every signature or plaintext is the correct result under the key's public components (`n`, `e`), or the operation fails. A backend never outputs a signature or plaintext computed from inconsistent key components, because a single faulty CRT signature reveals a prime factor of `n`. Handling an RSA private key with inconsistent or non-distinct components (wrong `dP`, `dQ`, `qInv` or `d`, or `p` = `q`) is implementation-defined. -The outcome may differ per call: each private-key call either returns [`Error::InvalidKey`] or behaves as specified for the key's public components (`n`, `e`), producing a signature or plaintext correct under them or the error specified for that input, such as [`Error::VerificationFailed`] when the correctly decrypted value has invalid padding. +The outcome may differ per call: each operation for which [`PrivateKey::supports`] is true, including [`PrivateKey::public_key`], either returns [`Error::InvalidKey`] or behaves as specified for the key's public components (`n`, `e`), producing a signature or plaintext correct under them or the error specified for that input, such as [`Error::VerificationFailed`] when the correctly decrypted value has invalid padding. For example, a backend may reject the key at loading with [`Error::InvalidKey`], reject it at the first private-key operation with [`Error::InvalidKey`], use CRT components the library recomputes from `n`, `e`, `d`, `p` and `q` when the primes are distinct, or check each result against (`n`, `e`). -Every one of these outcomes satisfies the checked-result property above. +Each of these is allowed only when every result satisfies the rules above. This is an exception to rule 4, which would otherwise require rejection at loading. -An inconsistency detected during [`PrivateKey::sign`] or [`PrivateKey::decrypt`] returns [`Error::InvalidKey`], never the padding error [`Error::VerificationFailed`], which applies only to a decrypted value correct under (`n`, `e`). +An inconsistency detected during [`PrivateKey::sign`] or [`PrivateKey::decrypt`] returns [`Error::InvalidKey`], never [`Error::VerificationFailed`], which here reports only invalid padding in a value decrypted correctly under (`n`, `e`) or a ciphertext not less than `n`. Conformance tests check these keys only as section 12 states. Derivability: @@ -812,18 +812,24 @@ For each of them, conformance tests assert that nothing panics and that the resu - RSA PKCS#1 v1.5 verification of a DigestInfo with absent parameters (section 6.9): success or [`Error::VerificationFailed`], the result section 6.9 gives for a signature that does not verify; - loading a PKCS#8 document that carries `attributes` (section 5.2): success or [`Error::InvalidKey`]; - a 3DES key whose component keys are not pairwise distinct or are DES weak or semi-weak keys (section 6.5): success or [`Error::InvalidKey`]; -- handling of an RSA private key with inconsistent or non-distinct components (section 7): loading returns [`Error::InvalidKey`], or every private-key operation either returns [`Error::InvalidKey`], produces a result that verifies under (`n`, `e`), or returns the error section 7 specifies for that input. +- handling of an RSA private key with inconsistent or non-distinct components (section 7): loading returns [`Error::InvalidKey`] or succeeds. + A loaded key returns [`Error::Unsupported`] for every operation for which [`PrivateKey::supports`] is false; for an operation for which it is true, only the outcomes below are allowed for each test input. + Exporting the public key returns [`Error::InvalidKey`] or exactly the section 5.1 encoding of the published (`n`, `e`) of the base key (below). + Signing returns [`Error::InvalidKey`] or a signature that verifies under (`n`, `e`). For decryption, the ciphertext comes from the first source that produces one for the algorithm: the base key's published ciphertexts, then the conformance runner's reference encryptor from another backend in the same build, then the provider's own encryptor. The expected plaintext is the published one for a published ciphertext, and the plaintext the suite encrypted for the other two sources. The check is never skipped: it fails if no source produces a ciphertext. + Keys derived from base keys published only with signature vectors get decryption checks in the same way. + Decryption returns [`Error::InvalidKey`] or the expected plaintext. + The suite uses only published ciphertexts labeled valid, so every ciphertext from any of the three sources has valid padding, and [`Error::VerificationFailed`] is a conformance failure for it, because a decryption correct under (`n`, `e`) recovers the valid padding. Each backend is checked on its own; differential runs do not repeat this check or compare outcomes between backends. -The inputs for that last property are derived from published keys, because the known published RSA test vectors contain no keys with inconsistent components: +The keys for that last property are derived from published keys, because the known published RSA test vectors contain no keys with inconsistent components: -- base keys come from cited published sources; -- an input is derived only by swapping or substituting whole encoded INTEGER fields between cited keys: `dP` with `dQ`, `p` with `q` leaving `qInv` unchanged, or `qInv` taken from another cited key of the same size; no arithmetic, no generated bytes and no randomness; +- base keys come from cited published sources and are in the must-support set of section 7; +- a key is derived only by swapping or substituting whole encoded INTEGER fields between cited keys: `dP` with `dQ`, `p` with `q` leaving `qInv` unchanged, or `qInv` taken from another cited key of the same size; no arithmetic, no generated bytes and no randomness; - round-trip control: splitting a base key into its fields and reassembling them unchanged must reproduce the cited key's exact bytes, otherwise the test fails; -- positive control: the unmodified base key loads and produces a result that verifies on the backend under test. +- positive control: the unmodified base key loads and, for each operation for which [`PrivateKey::supports`] is true, its exported public key is exactly the section 5.1 encoding of (`n`, `e`), its signatures verify and, with a ciphertext from the source chosen as for its derived keys, its decryptions return the expected plaintext on the backend under test. No known published key has non-distinct components (`p` = `q`, so `n` = `p`²), and these derivations cannot produce one, so that case has no conformance vector. From 4b8e8d092005c23557bfb661a135c3b1bd3f3b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 13 Oct 2026 18:01:56 +0900 Subject: [PATCH 05/30] test(picky-crypto): cover RSA key generation without extended runs The conformance suite generates RSA keys only in extended runs, so an inline test now generates a small key through the backend's seeded generation path and loads it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-rustcrypto/src/rsa.rs | 31 ++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/picky-crypto-rustcrypto/src/rsa.rs b/picky-crypto-rustcrypto/src/rsa.rs index 666dbaeb..1897d7fe 100644 --- a/picky-crypto-rustcrypto/src/rsa.rs +++ b/picky-crypto-rustcrypto/src/rsa.rs @@ -176,24 +176,27 @@ impl KeyGenerator for Generator { false } fn generate(&self) -> Result { - use rsa::rand_core::SeedableRng; let bits = match self.0 { KeyGenerationAlgorithm::Rsa2048 => 2048, KeyGenerationAlgorithm::Rsa3072 => 3072, KeyGenerationAlgorithm::Rsa4096 => 4096, _ => return Err(Error::Unsupported(Algorithm::KeyGeneration(self.0))), }; - let mut seed = Zeroizing::new([0u8; 32]); - getrandom::fill(&mut *seed).map_err(|_| Error::ProviderFailure)?; - // Infallible seeded RNG that wipes its buffer and state on drop (chacha20 0.10.2, src\rng.rs:94–98; - // src\lib.rs:306–310). - let mut rng = chacha20::ChaCha20Rng::from_seed(*seed); - let key = RsaPrivateKey::new_with_exp(&mut rng, bits, BoxedUint::from(65537u64)) - .map_err(|_| Error::ProviderFailure)?; - let document = key.to_pkcs8_der().map_err(|_| Error::ProviderFailure)?; - crate::util::output(document.as_bytes()) + generate_pkcs8(bits) } } +fn generate_pkcs8(bits: usize) -> Result { + use rsa::rand_core::SeedableRng; + let mut seed = Zeroizing::new([0u8; 32]); + getrandom::fill(&mut *seed).map_err(|_| Error::ProviderFailure)?; + // Infallible seeded RNG that wipes its buffer and state on drop (chacha20 0.10.2, src\rng.rs:94–98; + // src\lib.rs:306–310). + let mut rng = chacha20::ChaCha20Rng::from_seed(*seed); + let key = + RsaPrivateKey::new_with_exp(&mut rng, bits, BoxedUint::from(65537u64)).map_err(|_| Error::ProviderFailure)?; + let document = key.to_pkcs8_der().map_err(|_| Error::ProviderFailure)?; + crate::util::output(document.as_bytes()) +} fn signing_error(error: rsa::Error, algorithm: SignatureAlgorithm) -> Error { // An undersized modulus is MessageTooLong (rsa 0.10.0-rc.18, src\algorithms\pkcs1v15.rs:120–125). // Signing checks its result like decryption and reports a mismatch as Internal, mapped to InvalidKey @@ -398,6 +401,14 @@ mod tests { .unwrap(); } + // Conformance generates RSA keys only in extended runs, so this keeps seed expansion and PKCS#8 output covered. + #[test] + fn generated_key_loads() { + let document = generate_pkcs8(1024).unwrap(); + let key = Loader.load(PrivateKeyMaterial::Pkcs8(&document)).unwrap(); + assert_eq!(key.key_size_bits(), 1024); + } + #[test] fn small_modulus_and_large_exponent_are_unsupported() { use rsa::{rand_core::SeedableRng, traits::PrivateKeyParts}; From a51fe8a500ecdc49157fcb3fd8a682a5cb18fe1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 13 Oct 2026 18:41:14 +0900 Subject: [PATCH 06/30] docs(picky-crypto): let size limits take precedence over structure An input beyond a backend's size limit may be refused with Unsupported before its structure is examined, whether or not it is also malformed. Rule 4, the section 4 conformance expectations, section 6.9 and the section 12 eligibility rules follow it; a size limit lies above the must-support set and never applies to a length the algorithm or operation fixes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto/CONTRACT.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/picky-crypto/CONTRACT.md b/picky-crypto/CONTRACT.md index 9749f401..ba2b6f94 100644 --- a/picky-crypto/CONTRACT.md +++ b/picky-crypto/CONTRACT.md @@ -56,13 +56,13 @@ These rules apply to every operation and are not repeated. 4. **Valid domain and must-support set.** Each operation defines a valid domain (inputs that are well-formed for the algorithm) and, within it, a must-support set. For an advertised operation: - - An input outside the valid domain returns [`Error::InvalidKey`] (key material) or [`Error::InvalidInput`] (anything else), except that a verifier on a library with a single opaque failure may return [`Error::VerificationFailed`] for an unusable public key (section 4). + - An input outside the valid domain returns [`Error::InvalidKey`] (key material) or [`Error::InvalidInput`] (anything else), except that a verifier on a library with a single opaque failure may return [`Error::VerificationFailed`] for an unusable public key, and an input beyond a backend's size limit may return [`Error::Unsupported`] (section 4). - An input inside the valid domain but outside the must-support set is either processed like an input in the must-support set or rejected with [`Error::Unsupported`] naming the algorithm (or, for such a verifier, [`Error::VerificationFailed`], section 4). For example, a backend may refuse RSA private keys other than 2048, 3072 or 4096-bit two-prime keys with equal-size primes, because some libraries accept only those. - An input in the must-support set never returns [`Error::Unsupported`], except for policy input narrowing (section 8.2). - Where an operation states no explicit domain for a length, any length is valid and lengths up to 2^31 − 1 bytes are in the must-support set. - Valid domains include the limits of the standard that defines the algorithm (for example RFC 8018's maximum derived-key length). - Those limits are checked first, before allocation or any cryptographic processing, with overflow-safe arithmetic on [`usize`] arguments. + Those limits are checked before allocation or any cryptographic processing, with overflow-safe arithmetic on [`usize`] arguments. 5. **No RNG parameter.** Operations that need randomness (key generation, ephemeral keys, ECDSA nonces, RSA encryption padding, AES-GCM seal nonces) use the backend's own secure random generator. No generator is passed in, because some libraries accept only their own. @@ -144,9 +144,11 @@ The error set is closed: only the contract defines variants, never a backend, an When both [`Error::InvalidKey`] and [`Error::InvalidInput`] could apply, report [`Error::InvalidKey`]. Library key-validation failures map to [`Error::InvalidKey`], except when the library only rejects a well-formed key for its size or parameters, which maps to [`Error::Unsupported`] (rule 4). +Size limits take precedence: an input beyond a backend's size limit may be refused with [`Error::Unsupported`] before its structure is examined, whether or not it is also malformed. +A size limit lies above the must-support set and never applies to a length the algorithm or the operation fixes, such as those of rule 6. [`Error::ProviderFailure`] includes "PIN required", which callers therefore cannot distinguish from other device failures; this is a known limit of the closed set, to revisit if a consumer needs to prompt for a PIN. Some libraries report a single opaque failure for verification; a backend on such a library may report [`Error::VerificationFailed`] for a public key it cannot use, including an RSA modulus outside its range. -Conformance tests therefore expect [`Error::InvalidKey`] for a malformed public key, and accept either the result expected for a key in the must-support set or [`Error::Unsupported`] for a well-formed public key outside it; for a backend on such a library they also accept [`Error::VerificationFailed`] in both cases. +Conformance tests therefore expect [`Error::InvalidKey`] for a malformed public key within the backend's size limit, and accept either the result expected for a key in the must-support set or [`Error::Unsupported`] for a well-formed public key outside it; for a backend on such a library they also accept [`Error::VerificationFailed`] in both cases. For a public key in the must-support set with a wrong signature, they expect only [`Error::VerificationFailed`]. ## 5. Encodings @@ -394,7 +396,7 @@ It is an approved mode (NIST SP 800-38F) that must run inside a validated module | [`SignatureAlgorithm::EcdsaP256Sha256`], [`SignatureAlgorithm::EcdsaP384Sha384`], [`SignatureAlgorithm::EcdsaP521Sha512`] | FIPS 186-5 ECDSA with the named curve and hash, fixed `r \|\| s` | valid public points of the curve | | [`SignatureAlgorithm::Ed25519`] | RFC 8032 pure Ed25519 | valid public keys | -Valid domain: public key bytes in the encoding of section 5.1 and any signature bytes; a key that does not decode as the algorithm's key type in that encoding, including a compressed EC point, is [`Error::InvalidKey`] (or [`Error::VerificationFailed`] on a library with a single opaque failure, section 4). +Valid domain: public key bytes in the encoding of section 5.1 and any signature bytes; a key that does not decode as the algorithm's key type in that encoding, including a compressed EC point, is [`Error::InvalidKey`] (with the exceptions of section 4). Acceptance profile: @@ -407,7 +409,7 @@ Acceptance profile: Malformed inputs are errors: `S >= L`, an `R` that fails RFC 8032 section 5.1.3 decoding, or a wrong signature length is [`Error::VerificationFailed`]; an `A` that fails that decoding is [`Error::InvalidKey`] or [`Error::VerificationFailed`] (section 4). Verification with a validly encoded small-order `A` or `R` is implementation-defined (cofactored or cofactorless equation); backends may disagree on it, so differential tests exclude these inputs and conformance tests check the allowed outcomes listed in section 12. -Result: `Ok(())`; [`Error::VerificationFailed`] for a signature that does not verify, including a wrong length; [`Error::InvalidKey`] for a key that is not a valid key of the expected type, with the opaque-failure tolerance of section 4. +Result: `Ok(())`; [`Error::VerificationFailed`] for a signature that does not verify, including a wrong length; [`Error::InvalidKey`] for a key that is not a valid key of the expected type, with the exceptions of section 4. ### 6.10 Asymmetric encryption ([`AsymmetricEncryptor`]) @@ -791,7 +793,8 @@ Before a vector's label is applied, its eligibility is decided by the rules abov - for an absent entry or a key operation for which [`PrivateKey::supports`] is false, the suite asserts [`Error::Unsupported`] and does not apply the label; - MAC, cipher, AEAD and key-wrap vectors apply only to supported protections; - for a vector whose inputs are in the operation's must-support set, the label is applied as below; -- for a vector whose inputs are inside the valid domain but outside the must-support set (for example a 1024-bit RSA key), [`Error::Unsupported`] is also accepted, and so is [`Error::VerificationFailed`] from a verifier on a library with a single opaque failure (section 4); any other result must match the label. +- for a vector whose inputs are inside the valid domain but outside the must-support set (for example a 1024-bit RSA key), [`Error::Unsupported`] is also accepted, and so is [`Error::VerificationFailed`] from a verifier on a library with a single opaque failure (section 4); any other result must match the label; +- for a vector whose inputs are both beyond the backend's size limit and malformed, the allowed outcomes are [`Error::Unsupported`] or the error section 4 assigns to the malformed input ([`Error::InvalidKey`] for key material, [`Error::InvalidInput`] otherwise, or [`Error::VerificationFailed`] from a verifier on a library with a single opaque failure); well-formed inputs beyond the size limit follow the previous item. Labels follow Wycheproof: From c1f05506e7d5f3742fc4a71f598e024b61c769da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 13 Oct 2026 18:41:15 +0900 Subject: [PATCH 07/30] fix(picky-crypto): refuse oversized RSA encodings before decoding Public and private RSA key encodings longer than the largest encoding within the rsa crate's limits return Unsupported before any decoder runs, so no oversized input reaches code whose cost and stack use grow with its size. The caps are derived from the library's maximum modulus and exponent plus DER overhead. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-rustcrypto/README.md | 1 + picky-crypto-rustcrypto/src/rsa.rs | 102 +++++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 6 deletions(-) diff --git a/picky-crypto-rustcrypto/README.md b/picky-crypto-rustcrypto/README.md index cd0aa300..883b9a69 100644 --- a/picky-crypto-rustcrypto/README.md +++ b/picky-crypto-rustcrypto/README.md @@ -40,6 +40,7 @@ RSA signing and decryption blind the private-key operation with the operating-sy ECDSA signing mixes operating-system randomness into the RFC 6979 nonce as additional data. RSA public keys beyond the rsa crate's limits (a modulus above 8192 bits or a public exponent above 2^33 − 1) are unsupported. Private-key loading applies the same limits, although the rsa crate loads private keys of any modulus size. +Encoded RSA keys longer than the largest encoding within those limits are unsupported before they are decoded, whether or not they are also malformed. ## Conformance tests diff --git a/picky-crypto-rustcrypto/src/rsa.rs b/picky-crypto-rustcrypto/src/rsa.rs index 1897d7fe..e1eb2ba8 100644 --- a/picky-crypto-rustcrypto/src/rsa.rs +++ b/picky-crypto-rustcrypto/src/rsa.rs @@ -29,7 +29,43 @@ fn uint(bytes: &[u8], bits: u32) -> Result { BoxedUint::from_be_slice(bytes, bits).map_err(|_| Error::InvalidKey) } +/// DER length of a TLV with `content` bytes of content, in long-form length when needed. +const fn der_len(content: usize) -> usize { + let mut length_octets = 1; + if content >= 0x80 { + let mut rest = content; + while rest > 0 { + length_octets += 1; + rest >>= 8; + } + } + 1 + length_octets + content +} + +/// DER length of a positive INTEGER of at most `bits` bits, including a possible sign octet. +const fn der_integer_len(bits: usize) -> usize { + der_len(bits / 8 + 1) +} + +const MODULUS_LEN: usize = der_integer_len(RsaPublicKey::MAX_SIZE); +const EXPONENT_LEN: usize = der_integer_len((u64::BITS - RsaPublicKey::MAX_PUB_EXPONENT.leading_zeros()) as usize); + +/// Longest PKCS#1 `RSAPublicKey` within the library's limits (rsa 0.10.0-rc.18, src\key.rs:216–222). +const PUBLIC_KEY_MAX_LEN: usize = der_len(MODULUS_LEN + EXPONENT_LEN); + +/// Longest PKCS#8 RSA private key within the library's limits: version, n, e, then d, p, q, dP, dQ and qInv, each +/// bounded by the modulus, inside the version and `rsaEncryption` identifier of the PKCS#8 wrapper. +fn private_key_max_len() -> usize { + let rsa_private_key = der_len(der_integer_len(1) + MODULUS_LEN + EXPONENT_LEN + 6 * MODULUS_LEN); + let algorithm = der_len(der_len(rsa::pkcs1::ALGORITHM_OID.as_bytes().len()) + der_len(0)); + der_len(der_integer_len(1) + algorithm + der_len(rsa_private_key)) +} + fn load_public_key(der: &[u8], algorithm: Algorithm) -> Result { + // Size limits take precedence over structure, so no oversized input reaches the library's decoders. + if der.len() > PUBLIC_KEY_MAX_LEN { + return Err(Error::Unsupported(algorithm)); + } let parsed = rsa::pkcs1::RsaPublicKey::from_der(der).map_err(|_| Error::InvalidKey)?; let n = uint(parsed.modulus.as_bytes(), precision(parsed.modulus.as_bytes())?)?; let e = uint( @@ -138,10 +174,10 @@ impl PrivateKeyLoader for Loader { let PrivateKeyMaterial::Pkcs8(der) = material else { return Err(Error::InvalidKey); }; - // The library's PKCS#1 decoder multiplies the public-exponent length by eight in u32 - // (rsa 0.10.0-rc.18, src\encoding.rs:55–57). - if der.len() as u128 > u128::from(u32::MAX / 8) { - return Err(Error::InvalidKey); + // Size limits take precedence over structure, so no oversized input reaches the library's decoders, whose + // stack use grows with the input. + if der.len() > private_key_max_len() { + return Err(Error::Unsupported(Algorithm::PrivateKeyLoading(KeyType::Rsa))); } let info = PrivateKeyInfoRef::from_der(der).map_err(|_| Error::InvalidKey)?; if info.version() != Version::V1 { @@ -318,7 +354,10 @@ pub(crate) fn entries(builder: ProviderBuilder) -> ProviderBuilder { #[cfg(test)] mod tests { use super::*; - use rsa::pkcs8::der::{Encode, asn1::UintRef}; + use rsa::pkcs8::der::{ + Encode, + asn1::{OctetStringRef, UintRef}, + }; #[test] fn rsa_limits_preserve_operation_identity() { @@ -359,7 +398,6 @@ mod tests { #[test] fn private_modulus_above_public_limit_is_unsupported() { - use rsa::pkcs8::der::asn1::OctetStringRef; let one = UintRef::new(&[1]).unwrap(); let modulus = [0xffu8; 1025]; for (len, expected) in [ @@ -385,6 +423,58 @@ mod tests { } } + // The caps are the library's encodings of the largest keys within its limits; longer inputs are refused undecoded. + #[test] + fn encoded_length_caps() { + let algorithm = SignatureAlgorithm::RsaPkcs1v15Sha256; + let loading = Algorithm::PrivateKeyLoading(KeyType::Rsa); + let n = UintRef::new(&[0xff; RsaPublicKey::MAX_SIZE / 8]).unwrap(); + let exponent = RsaPublicKey::MAX_PUB_EXPONENT.to_be_bytes(); + let e = UintRef::new(&exponent).unwrap(); + let public = rsa::pkcs1::RsaPublicKey { + modulus: n, + public_exponent: e, + }; + assert_eq!(public.to_der().unwrap().len(), PUBLIC_KEY_MAX_LEN); + let private = rsa::pkcs1::RsaPrivateKey { + modulus: n, + public_exponent: e, + private_exponent: n, + prime1: n, + prime2: n, + exponent1: n, + exponent2: n, + coefficient: n, + other_prime_infos: None, + } + .to_der() + .unwrap(); + let info = PrivateKeyInfoRef::new(rsa::pkcs1::ALGORITHM_ID, OctetStringRef::new(&private).unwrap()); + // Catches a private cap that miscounts the modulus-bounded fields. + assert_eq!(info.to_der().unwrap().len(), private_key_max_len()); + for (len, expected) in [ + (PUBLIC_KEY_MAX_LEN, Error::InvalidKey), + ( + PUBLIC_KEY_MAX_LEN + 1, + Error::Unsupported(Algorithm::Signature(algorithm)), + ), + ] { + assert_eq!( + Verify(algorithm).verify(PublicKey(&vec![0; len]), b"", b""), + Err(expected) + ); + } + for (len, expected) in [ + (private_key_max_len(), Error::InvalidKey), + (private_key_max_len() + 1, Error::Unsupported(loading)), + ] { + assert_eq!( + Loader.load(PrivateKeyMaterial::Pkcs8(&vec![0; len])).err(), + Some(expected) + ); + } + } + // Signatures must be padded to the modulus width, not to whole limbs (Signature::to_bytes()). #[test] fn rsa_outputs_use_modulus_width() { From f94434d5a8265f0fd1e86fe091ed3995820e975d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 13 Oct 2026 21:00:12 +0900 Subject: [PATCH 08/30] docs(picky-crypto): order bound, size-limit and structure checks Lengths fixed by the algorithm or operation and ranges defined by the algorithm's standard bound the valid domain and return the specified error on every backend, never Unsupported. They are checked first, then a backend's size limits, a capacity bound inside the valid domain above the must-support set, then structure. Section 12 lists the boundary inputs the suite checks and their in-range controls. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto/CONTRACT.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/picky-crypto/CONTRACT.md b/picky-crypto/CONTRACT.md index ba2b6f94..f2ff378b 100644 --- a/picky-crypto/CONTRACT.md +++ b/picky-crypto/CONTRACT.md @@ -56,13 +56,13 @@ These rules apply to every operation and are not repeated. 4. **Valid domain and must-support set.** Each operation defines a valid domain (inputs that are well-formed for the algorithm) and, within it, a must-support set. For an advertised operation: - - An input outside the valid domain returns [`Error::InvalidKey`] (key material) or [`Error::InvalidInput`] (anything else), except that a verifier on a library with a single opaque failure may return [`Error::VerificationFailed`] for an unusable public key, and an input beyond a backend's size limit may return [`Error::Unsupported`] (section 4). + - An input outside the valid domain returns [`Error::InvalidKey`] (key material) or [`Error::InvalidInput`] (anything else), except that a verifier on a library with a single opaque failure may return [`Error::VerificationFailed`] for an unusable public key, and a structurally malformed input within the bounds below but beyond a backend's size limit may return [`Error::Unsupported`] (section 4). - An input inside the valid domain but outside the must-support set is either processed like an input in the must-support set or rejected with [`Error::Unsupported`] naming the algorithm (or, for such a verifier, [`Error::VerificationFailed`], section 4). For example, a backend may refuse RSA private keys other than 2048, 3072 or 4096-bit two-prime keys with equal-size primes, because some libraries accept only those. - An input in the must-support set never returns [`Error::Unsupported`], except for policy input narrowing (section 8.2). - Where an operation states no explicit domain for a length, any length is valid and lengths up to 2^31 − 1 bytes are in the must-support set. - - Valid domains include the limits of the standard that defines the algorithm (for example RFC 8018's maximum derived-key length). - Those limits are checked before allocation or any cryptographic processing, with overflow-safe arithmetic on [`usize`] arguments. + - Lengths fixed by the algorithm or the operation, and ranges defined by the algorithm's standard (for example RFC 8018's maximum derived-key length, the RC2 and RC4 key ranges), bound the valid domain: an input outside them returns the specified error on every backend, never [`Error::Unsupported`]. + These bounds are checked first, before allocation or any cryptographic processing, with overflow-safe arithmetic on [`usize`] arguments; size limits (section 4) come next, which may return [`Error::Unsupported`], and structure last, which returns the error the first item above assigns. 5. **No RNG parameter.** Operations that need randomness (key generation, ephemeral keys, ECDSA nonces, RSA encryption padding, AES-GCM seal nonces) use the backend's own secure random generator. No generator is passed in, because some libraries accept only their own. @@ -145,6 +145,7 @@ The error set is closed: only the contract defines variants, never a backend, an When both [`Error::InvalidKey`] and [`Error::InvalidInput`] could apply, report [`Error::InvalidKey`]. Library key-validation failures map to [`Error::InvalidKey`], except when the library only rejects a well-formed key for its size or parameters, which maps to [`Error::Unsupported`] (rule 4). Size limits take precedence: an input beyond a backend's size limit may be refused with [`Error::Unsupported`] before its structure is examined, whether or not it is also malformed. +A size limit is a backend's capacity bound inside the valid domain, above the must-support set. A size limit lies above the must-support set and never applies to a length the algorithm or the operation fixes, such as those of rule 6. [`Error::ProviderFailure`] includes "PIN required", which callers therefore cannot distinguish from other device failures; this is a known limit of the closed set, to revisit if a consumer needs to prompt for a PIN. Some libraries report a single opaque failure for verification; a backend on such a library may report [`Error::VerificationFailed`] for a public key it cannot use, including an RSA modulus outside its range. @@ -794,7 +795,11 @@ Before a vector's label is applied, its eligibility is decided by the rules abov - MAC, cipher, AEAD and key-wrap vectors apply only to supported protections; - for a vector whose inputs are in the operation's must-support set, the label is applied as below; - for a vector whose inputs are inside the valid domain but outside the must-support set (for example a 1024-bit RSA key), [`Error::Unsupported`] is also accepted, and so is [`Error::VerificationFailed`] from a verifier on a library with a single opaque failure (section 4); any other result must match the label; -- for a vector whose inputs are both beyond the backend's size limit and malformed, the allowed outcomes are [`Error::Unsupported`] or the error section 4 assigns to the malformed input ([`Error::InvalidKey`] for key material, [`Error::InvalidInput`] otherwise, or [`Error::VerificationFailed`] from a verifier on a library with a single opaque failure); well-formed inputs beyond the size limit follow the previous item. +- for a vector whose inputs are within the fixed lengths and standard ranges of rule 4 but both beyond the backend's size limit and malformed, the allowed outcomes are [`Error::Unsupported`] or the error section 4 assigns to the malformed input ([`Error::InvalidKey`] for key material, [`Error::InvalidInput`] otherwise, or [`Error::VerificationFailed`] from a verifier on a library with a single opaque failure); well-formed inputs beyond the size limit follow the previous item. + +Inputs just outside a length the algorithm or the operation fixes, or a range the algorithm's standard defines, expect the specified error and never [`Error::Unsupported`] (rule 4). +They are hand-written structural inputs, and the suite checks at least RC2 keys of 0 and 129 bytes, RC4 keys of 0 and 257 bytes, and PBKDF2 with zero iterations, an empty output or an output one byte above the RFC 8018 maximum where [`usize`] can represent it. +Each of these has a control at the adjacent in-range value, either a published input or the in-range value with published other inputs, which succeeds, or returns [`Error::Unsupported`] when that value is outside the must-support set. Labels follow Wycheproof: From a9cb15aa231a91ac5c96cd949956aca9944e3cba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 13 Oct 2026 21:00:12 +0900 Subject: [PATCH 09/30] test(picky-crypto): control the RC2, RC4 and PBKDF2 range boundaries Each rejected boundary just outside a standard range now has a control at the adjacent in-range value: RC2 keys of 1 and 128 bytes and an RC4 key of 1 byte from published messages, and PBKDF2 with one iteration and a one-byte output and with an output at the RFC 8018 maximum, using a published password and salt. Values outside the must-support set may also return Unsupported. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-testsuite/src/areas/cipher.rs | 12 ++++++ .../src/areas/password_kdf.rs | 37 +++++++++++++++---- .../src/areas/stream_cipher.rs | 8 ++++ picky-crypto-testsuite/src/select.rs | 10 +++++ 4 files changed, 60 insertions(+), 7 deletions(-) diff --git a/picky-crypto-testsuite/src/areas/cipher.rs b/picky-crypto-testsuite/src/areas/cipher.rs index 3440afaf..45df6c00 100644 --- a/picky-crypto-testsuite/src/areas/cipher.rs +++ b/picky-crypto-testsuite/src/areas/cipher.rs @@ -130,6 +130,18 @@ pub fn run(p: &CryptoProvider, _: Options) { || transform(e, protection, &wrong, iv, data), ); } + if a == CipherAlgorithm::Rc2Cbc { + // Controls for the rejected lengths 0 and 129: the adjacent in-range lengths, outside the must-support set. + for length in [1, 128] { + let (source, in_range) = select::message_of_length(length); + let id = format!("{a:?}/{protection:?}/key length={length}/{source}"); + if let Some(out) = c.outcome(&id, Expect::Success, Some((Algorithm::Cipher(a), false)), || { + transform(e, protection, &in_range, iv, data) + }) { + c.check(&id, out.len() == data.len(), "output length"); + } + } + } } if a == CipherAlgorithm::TdesEde3Cbc { weak_tdes_checks(&mut c, e, select::cbc_control(a, &records), protections); diff --git a/picky-crypto-testsuite/src/areas/password_kdf.rs b/picky-crypto-testsuite/src/areas/password_kdf.rs index a6bb7ea3..38859c04 100644 --- a/picky-crypto-testsuite/src/areas/password_kdf.rs +++ b/picky-crypto-testsuite/src/areas/password_kdf.rs @@ -19,7 +19,19 @@ pub fn run(p: &CryptoProvider, _: Options) { } }; let file = format!("pbkdf2_hmac{sha}_test.json"); - for group in v::wycheproof(&file).test_groups { + let vectors = v::wycheproof(&file); + // Bound checks use a published password and salt, so they can fail only for the bound under test. + let (source, password, salt) = vectors + .test_groups + .iter() + .flat_map(v::tests) + .find(|t| v::string(t, "result") == "valid") + .map(|t| { + let source = format!("{file}/tcId={}", v::number(t, "tcId")); + (source, v::field(t, "password"), v::field(t, "salt")) + }) + .expect("a valid PBKDF2 test"); + for group in vectors.test_groups { for t in v::tests(&group) { let id = v::id(a, &file, t); let iterations = v::number(t, "iterationCount"); @@ -41,14 +53,25 @@ pub fn run(p: &CryptoProvider, _: Options) { } } } - for (iterations, length) in [(0, 1), (1, 0)] - .into_iter() - .chain(maximum(HASHES[index + 2].output_len()).map(|n| (1, n))) - { + let maximum = maximum(HASHES[index + 2].output_len()); + for (iterations, length) in [(0, 1), (1, 0)].into_iter().chain(maximum.map(|n| (1, n))) { c.call( - &format!("{a:?}/bounds/{iterations}/{length}"), + &format!("{a:?}/bounds/{iterations}/{length}/{source}"), Expect::Error(Error::InvalidInput), - || e.derive(&[], &[], iterations, length), + || e.derive(&password, &salt, iterations, length), + ); + } + // Controls: one iteration with a one-byte output is in the must-support set, and the RFC 8018 maximum is in the valid domain. + let id = format!("{a:?}/bounds/1/1/{source}"); + if let Some(out) = c.call(&id, Expect::Success, || e.derive(&password, &salt, 1, 1)) { + c.check(&id, out.len() == 1, "output length"); + } + if let Some(n) = maximum { + c.outcome( + &format!("{a:?}/bounds/1/{}/{source}", n - 1), + Expect::Success, + Some((Algorithm::PasswordKdf(a), false)), + || e.derive(&password, &salt, 1, n - 1).map(|_| ()), ); } if a == PasswordKdfAlgorithm::Pbkdf2HmacSha1 { diff --git a/picky-crypto-testsuite/src/areas/stream_cipher.rs b/picky-crypto-testsuite/src/areas/stream_cipher.rs index 97622e5e..f6c17fb6 100644 --- a/picky-crypto-testsuite/src/areas/stream_cipher.rs +++ b/picky-crypto-testsuite/src/areas/stream_cipher.rs @@ -41,6 +41,14 @@ pub fn run(p: &CryptoProvider, _: Options) { c.check(&id, parts == whole.as_ref(), "two calls must continue one keystream"); c.bytes(&format!("{id}/applied twice"), &restored, message); } + // Control for the rejected length 0: the adjacent in-range length, outside the must-support set; 256 bytes is checked above. + let (source, key) = select::message_of_length(1); + let id = format!("{a:?}/key length=1/{source}"); + if let Some(out) = c.outcome(&id, Expect::Success, Some((Algorithm::StreamCipher(a), false)), || { + e.start(&key)?.apply(&key) + }) { + c.check(&id, out.len() == key.len(), "output length"); + } for length in [0, 257] { c.call( &format!("{a:?}/key length={length}"), diff --git a/picky-crypto-testsuite/src/select.rs b/picky-crypto-testsuite/src/select.rs index 0bbc8186..e318d800 100644 --- a/picky-crypto-testsuite/src/select.rs +++ b/picky-crypto-testsuite/src/select.rs @@ -211,6 +211,16 @@ pub fn rc4_longest_key() -> (String, Vec) { (format!("{file}/tcId={}/ct", v::number(t, "tcId")), v::field(t, "ct")) } +/// A published message of exactly `len` bytes, used as an in-range key next to a rejected length; checks using it expect only contract properties. +pub fn message_of_length(len: usize) -> (String, Vec) { + let file = "hmac_sha1_test.json"; + let vectors = v::wycheproof(file); + let (_, t) = test(file, &vectors, &format!("{len}-byte message"), |_, t| { + v::field(t, "msg").len() == len + }); + (format!("{file}/tcId={}/msg", v::number(t, "tcId")), v::field(t, "msg")) +} + pub fn rsa_plaintext(limit: usize) -> (String, Vec) { required( "rfc/rfc1320.txt; rfc/rfc8032.txt", From 4beaaf189a904dd362f005624706a8f416435a82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 13 Oct 2026 21:00:12 +0900 Subject: [PATCH 10/30] fix(picky-crypto): bound PBKDF2 output before allocation Derived output above 2^31 - 1 bytes is refused with Unsupported before allocation, so a request at the RFC 8018 maximum cannot allocate tens of gigabytes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-rustcrypto/src/pbkdf2.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/picky-crypto-rustcrypto/src/pbkdf2.rs b/picky-crypto-rustcrypto/src/pbkdf2.rs index f5e40b8d..03af54b4 100644 --- a/picky-crypto-rustcrypto/src/pbkdf2.rs +++ b/picky-crypto-rustcrypto/src/pbkdf2.rs @@ -3,6 +3,8 @@ use picky_crypto::{Algorithm, Entry, Error, OutputBytes, PasswordKdf, PasswordKd use std::{marker::PhantomData, sync::Arc}; const MUST_SUPPORT_MAX_OUTPUT_LEN: usize = 1024; +/// This backend's size limit on derived output (2^31 − 1 bytes), far above the 1024-byte must-support maximum. +const OUTPUT_SIZE_LIMIT: usize = i32::MAX as usize; struct Pbkdf2(PasswordKdfAlgorithm, PhantomData); impl PasswordKdf for Pbkdf2 { @@ -19,6 +21,9 @@ impl PasswordKdf for Pbkdf2 { { return Err(Error::InvalidInput); } + if output_len > OUTPUT_SIZE_LIMIT { + return Err(Error::Unsupported(Algorithm::PasswordKdf(self.0))); + } // Only lengths beyond the must-support maximum may be refused as Unsupported. let mut output = crate::util::buffer(output_len).map_err(|_| { if output_len > MUST_SUPPORT_MAX_OUTPUT_LEN { From 4e96cabf90221b89cc343d226625601776f5c9d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 13 Oct 2026 21:18:11 +0900 Subject: [PATCH 11/30] fix(picky-crypto): refuse oversized cipher data before allocation CBC data, RC4 data and AES-GCM ciphertext with tag above 2^31 - 1 bytes, the contract's must-support maximum, return Unsupported after the fixed-length and standard-range checks and before any copy, rather than surfacing an allocation failure as ProviderFailure. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-rustcrypto/src/aead.rs | 44 +++++++++++++++++++++-------- picky-crypto-rustcrypto/src/cbc.rs | 15 +++++++--- picky-crypto-rustcrypto/src/rc4.rs | 6 ++++ 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/picky-crypto-rustcrypto/src/aead.rs b/picky-crypto-rustcrypto/src/aead.rs index f13c2dd1..c65bd486 100644 --- a/picky-crypto-rustcrypto/src/aead.rs +++ b/picky-crypto-rustcrypto/src/aead.rs @@ -6,16 +6,28 @@ use picky_crypto::{Aead, AeadAlgorithm, Entry, Error, OutputBytes, Protection, P use std::sync::Arc; struct Gcm(AeadAlgorithm); + +/// This backend's size limit on `ciphertext || tag`, the contract's must-support maximum (2^31 − 1 bytes). +const SIZE_LIMIT: usize = i32::MAX as usize; +const TAG_LEN: usize = 16; + impl Gcm { - fn seal_with(key: &[u8], aad: &[u8], plaintext: &[u8]) -> Result { + fn seal_with( + algorithm: AeadAlgorithm, + key: &[u8], + aad: &[u8], + plaintext: &[u8], + ) -> Result { let key: &Key = key.try_into().map_err(|_| Error::InvalidKey)?; check_lengths(aad.len(), plaintext.len())?; + if plaintext.len() > SIZE_LIMIT - TAG_LEN { + return Err(Error::Unsupported(picky_crypto::Algorithm::Aead(algorithm))); + } let cipher = C::new(key); let nonce = Zeroizing::new( Nonce::::try_generate_from_rng(&mut getrandom::SysRng).map_err(|_| Error::ProviderFailure)?, ); - let capacity = plaintext.len().checked_add(16).ok_or(Error::InvalidInput)?; - let mut output = crate::util::buffer(capacity)?; + let mut output = crate::util::buffer(plaintext.len() + TAG_LEN)?; output.truncate(plaintext.len()); output.copy_from_slice(plaintext); cipher @@ -25,17 +37,21 @@ impl Gcm { } fn open_with( + algorithm: AeadAlgorithm, key: &[u8], nonce: &[u8], aad: &[u8], ciphertext: &[u8], ) -> Result { let key: &Key = key.try_into().map_err(|_| Error::InvalidKey)?; - if ciphertext.len() < 16 { + if ciphertext.len() < TAG_LEN { return Err(Error::InvalidInput); } - check_lengths(aad.len(), ciphertext.len() - 16)?; + check_lengths(aad.len(), ciphertext.len() - TAG_LEN)?; let nonce = nonce.try_into().map_err(|_| Error::InvalidInput)?; + if ciphertext.len() > SIZE_LIMIT { + return Err(Error::Unsupported(picky_crypto::Algorithm::Aead(algorithm))); + } let cipher = C::new(key); let mut output = crate::util::output(ciphertext)?.into_inner(); cipher @@ -63,17 +79,23 @@ impl Aead for Gcm { } fn seal(&self, key: &[u8], aad: &[u8], plaintext: &[u8]) -> Result { match self.0 { - AeadAlgorithm::Aes128Gcm => Self::seal_with::>(key, aad, plaintext), - AeadAlgorithm::Aes192Gcm => Self::seal_with::>(key, aad, plaintext), - AeadAlgorithm::Aes256Gcm => Self::seal_with::>(key, aad, plaintext), + AeadAlgorithm::Aes128Gcm => Self::seal_with::>(self.0, key, aad, plaintext), + AeadAlgorithm::Aes192Gcm => Self::seal_with::>(self.0, key, aad, plaintext), + AeadAlgorithm::Aes256Gcm => Self::seal_with::>(self.0, key, aad, plaintext), _ => Err(Error::Unsupported(picky_crypto::Algorithm::Aead(self.0))), } } fn open(&self, key: &[u8], nonce: &[u8], aad: &[u8], ciphertext: &[u8]) -> Result { match self.0 { - AeadAlgorithm::Aes128Gcm => Self::open_with::>(key, nonce, aad, ciphertext), - AeadAlgorithm::Aes192Gcm => Self::open_with::>(key, nonce, aad, ciphertext), - AeadAlgorithm::Aes256Gcm => Self::open_with::>(key, nonce, aad, ciphertext), + AeadAlgorithm::Aes128Gcm => { + Self::open_with::>(self.0, key, nonce, aad, ciphertext) + } + AeadAlgorithm::Aes192Gcm => { + Self::open_with::>(self.0, key, nonce, aad, ciphertext) + } + AeadAlgorithm::Aes256Gcm => { + Self::open_with::>(self.0, key, nonce, aad, ciphertext) + } _ => Err(Error::Unsupported(picky_crypto::Algorithm::Aead(self.0))), } } diff --git a/picky-crypto-rustcrypto/src/cbc.rs b/picky-crypto-rustcrypto/src/cbc.rs index 625e901c..93fafb86 100644 --- a/picky-crypto-rustcrypto/src/cbc.rs +++ b/picky-crypto-rustcrypto/src/cbc.rs @@ -7,7 +7,7 @@ impl Cbc { fn run(&self, key: &[u8], iv: &[u8], data: &[u8], encrypt: bool) -> Result { macro_rules! fixed { ($cipher:ty, $key_len:expr, $block_len:expr) => {{ - check(key.len() == $key_len, data, $block_len)?; + check(self.0, key.len() == $key_len, iv, data, $block_len)?; transform( <$cipher>::new_from_slice(key).map_err(|_| Error::InvalidKey)?, iv, @@ -27,7 +27,7 @@ impl Cbc { CipherAlgorithm::TdesEde3Cbc => fixed!(des::TdesEde3, 24, 8), #[cfg(feature = "legacy")] CipherAlgorithm::Rc2Cbc => { - check((1..=128).contains(&key.len()), data, 8)?; + check(self.0, (1..=128).contains(&key.len()), iv, data, 8)?; transform(rc2::Rc2::new_with_eff_key_len(key, 8 * key.len()), iv, data, encrypt) } _ => Err(Error::Unsupported(picky_crypto::Algorithm::Cipher(self.0))), @@ -35,13 +35,20 @@ impl Cbc { } } -fn check(key_valid: bool, data: &[u8], block: usize) -> Result<(), Error> { +/// This backend's size limit on data, the contract's must-support maximum for CBC data (2^31 − 1 bytes). +const DATA_SIZE_LIMIT: usize = i32::MAX as usize; + +/// Checks fixed lengths first, then the size limit, so oversized data is never copied. +fn check(algorithm: CipherAlgorithm, key_valid: bool, iv: &[u8], data: &[u8], block: usize) -> Result<(), Error> { if !key_valid { return Err(Error::InvalidKey); } - if data.len() % block != 0 { + if iv.len() != block || data.len() % block != 0 { return Err(Error::InvalidInput); } + if data.len() > DATA_SIZE_LIMIT { + return Err(Error::Unsupported(picky_crypto::Algorithm::Cipher(algorithm))); + } Ok(()) } diff --git a/picky-crypto-rustcrypto/src/rc4.rs b/picky-crypto-rustcrypto/src/rc4.rs index 0c34bae8..d647c610 100644 --- a/picky-crypto-rustcrypto/src/rc4.rs +++ b/picky-crypto-rustcrypto/src/rc4.rs @@ -30,6 +30,12 @@ impl StreamCipherContext for Context { return Err(Error::ProviderFailure); } let result = (|| { + // This backend's size limit on data, the contract's general must-support bound (2^31 − 1 bytes). + if data.len() > i32::MAX as usize { + return Err(Error::Unsupported(picky_crypto::Algorithm::StreamCipher( + StreamCipherAlgorithm::Rc4, + ))); + } let mut output = crate::util::output(data)?.into_inner(); self.cipher .try_apply_keystream(&mut output) From ffbdfb6587eab9740d2009f6aa734f7cb3dbbee9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 13 Oct 2026 23:18:51 +0900 Subject: [PATCH 12/30] docs(picky-crypto): allow boundary controls for hand-written inputs A hand-written input never carries a computed expected output: it expects a specified error, or, as a control at a boundary the contract defines, acceptance checked only through contract properties, and no control may be large enough to exhaust time or memory. Section 12 drops the PBKDF2 control at the RFC 8018 maximum, records the inward off-by-one there as a known gap, adds a control just above the must-support output length and compares control outputs in differential runs. The redundant size-limit sentence in section 4 is removed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-testsuite/AGENTS.md | 5 ++++- picky-crypto-testsuite/INTENT.md | 4 +++- picky-crypto/CONTRACT.md | 6 ++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/picky-crypto-testsuite/AGENTS.md b/picky-crypto-testsuite/AGENTS.md index 7d41229e..bbafb870 100644 --- a/picky-crypto-testsuite/AGENTS.md +++ b/picky-crypto-testsuite/AGENTS.md @@ -13,11 +13,14 @@ Sources, versions and checksums are in `vectors/manifest.toml`; `cargo xtask che - To update a vendored source, replace its files wholesale and rerun the suite against every backend. - Never edit a vector file or trim inside a group. - A boundary value may be derived from a published value by a change confined to known bytes and asserted in the test (for example p − 1 for odd p: only the last byte differs). - Like other hand-written inputs, it only ever expects an error. + Like other hand-written inputs, it expects a specified error, or acceptance only as a control at a boundary the contract defines (`INTENT.md`). - Report newly failing vectors, and the size of any vendored data added. ## Hand-written inputs - Structural inputs are not limited to empty, zero, one, or lengths at or beyond a limit. - A hand-written input that targets one check carries a control in the test asserting that every other check holds, so it can fail only for the intended reason. +- A control sits at a boundary the contract defines: the accepted value next to a rejected range limit, or just above the must-support set, where it accepts success or `Unsupported`. + It asserts only contract properties, and differential runs compare its output between backends when it has no published answer. +- Keep controls small: omit any control whose processing could exhaust a conforming backend's time or memory, and record the boundary it leaves unchecked as a known gap in `CONTRACT.md` section 12. - Prefer deriving such inputs from published ones. diff --git a/picky-crypto-testsuite/INTENT.md b/picky-crypto-testsuite/INTENT.md index ce5ae598..89955262 100644 --- a/picky-crypto-testsuite/INTENT.md +++ b/picky-crypto-testsuite/INTENT.md @@ -24,7 +24,9 @@ It is the executable form of the contract: a backend is correct when it passes t Every re-encoded key is also used by at least one published vector that expects success, so an encoding error can't make failure vectors pass vacuously. - A vector is an input paired with a computed expected output. Literal structural inputs whose expected outcome is a specified error (empty, zero, one, lengths at or beyond a limit) are not vectors and may be written by hand. - A hand-written input never expects success. + A hand-written input never carries a computed expected output. + It expects a specified error, or, as a control at a boundary the contract defines (a range limit or the edge of the must-support set), acceptance (success, or `Unsupported` above the must-support set), checked only through properties the contract states. + No control is so large that processing it could exhaust time or memory. ## Required behavior diff --git a/picky-crypto/CONTRACT.md b/picky-crypto/CONTRACT.md index f2ff378b..9f7610b5 100644 --- a/picky-crypto/CONTRACT.md +++ b/picky-crypto/CONTRACT.md @@ -146,7 +146,6 @@ When both [`Error::InvalidKey`] and [`Error::InvalidInput`] could apply, report Library key-validation failures map to [`Error::InvalidKey`], except when the library only rejects a well-formed key for its size or parameters, which maps to [`Error::Unsupported`] (rule 4). Size limits take precedence: an input beyond a backend's size limit may be refused with [`Error::Unsupported`] before its structure is examined, whether or not it is also malformed. A size limit is a backend's capacity bound inside the valid domain, above the must-support set. -A size limit lies above the must-support set and never applies to a length the algorithm or the operation fixes, such as those of rule 6. [`Error::ProviderFailure`] includes "PIN required", which callers therefore cannot distinguish from other device failures; this is a known limit of the closed set, to revisit if a consumer needs to prompt for a PIN. Some libraries report a single opaque failure for verification; a backend on such a library may report [`Error::VerificationFailed`] for a public key it cannot use, including an RSA modulus outside its range. Conformance tests therefore expect [`Error::InvalidKey`] for a malformed public key within the backend's size limit, and accept either the result expected for a key in the must-support set or [`Error::Unsupported`] for a well-formed public key outside it; for a backend on such a library they also accept [`Error::VerificationFailed`] in both cases. @@ -799,7 +798,10 @@ Before a vector's label is applied, its eligibility is decided by the rules abov Inputs just outside a length the algorithm or the operation fixes, or a range the algorithm's standard defines, expect the specified error and never [`Error::Unsupported`] (rule 4). They are hand-written structural inputs, and the suite checks at least RC2 keys of 0 and 129 bytes, RC4 keys of 0 and 257 bytes, and PBKDF2 with zero iterations, an empty output or an output one byte above the RFC 8018 maximum where [`usize`] can represent it. -Each of these has a control at the adjacent in-range value, either a published input or the in-range value with published other inputs, which succeeds, or returns [`Error::Unsupported`] when that value is outside the must-support set. +Each of these, except the PBKDF2 output above the maximum, has a control at the adjacent in-range value, either a published input or the in-range value with published other inputs, which succeeds, or returns [`Error::Unsupported`] when that value is outside the must-support set. +PBKDF2 also has a control at the edge of the must-support set, an output one byte above it, which succeeds or returns [`Error::Unsupported`], never [`Error::InvalidInput`]. +In differential runs, the outputs of these controls are also compared between the two backends whenever both produce one and the control has no published answer. +Known gap: an inward off-by-one at the PBKDF2 maximum output length is not detected, because a control at that length could exhaust a conforming backend's time or memory. Labels follow Wycheproof: From f058e49842e564efe7c3ea1f163f8b97ae66e731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 13 Oct 2026 23:18:51 +0900 Subject: [PATCH 13/30] test(picky-crypto): compare boundary control outputs across providers The RC2, RC4 and PBKDF2 boundary controls have no published answer, so differential runs now compare their outputs between providers. The PBKDF2 control at the RFC 8018 maximum is replaced by one just above the must-support output length, which a backend must accept or refuse with Unsupported, never InvalidInput. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/areas/password_kdf.rs | 35 +++++------ .../src/differential/symmetric.rs | 62 ++++++++++++++++++- picky-crypto-testsuite/src/select.rs | 12 ++++ picky-crypto-testsuite/tests/differential.rs | 44 +++++++++++++ 4 files changed, 131 insertions(+), 22 deletions(-) diff --git a/picky-crypto-testsuite/src/areas/password_kdf.rs b/picky-crypto-testsuite/src/areas/password_kdf.rs index 38859c04..5b7fe3a5 100644 --- a/picky-crypto-testsuite/src/areas/password_kdf.rs +++ b/picky-crypto-testsuite/src/areas/password_kdf.rs @@ -4,10 +4,13 @@ use picky_crypto::*; use crate::algorithms::*; use crate::harness::{Checks, Expect, Options}; -use crate::vectors as v; +use crate::{select, vectors as v}; use super::kdf::maximum; +/// The largest PBKDF2 output length in the must-support set (contract section 6.3). +pub const MUST_SUPPORT_OUTPUT_LEN: usize = 1024; + pub fn run(p: &CryptoProvider, _: Options) { let mut c = Checks::default(); for (index, (a, sha)) in PASSWORD_KDFS.into_iter().zip(SHAS).enumerate() { @@ -21,16 +24,7 @@ pub fn run(p: &CryptoProvider, _: Options) { let file = format!("pbkdf2_hmac{sha}_test.json"); let vectors = v::wycheproof(&file); // Bound checks use a published password and salt, so they can fail only for the bound under test. - let (source, password, salt) = vectors - .test_groups - .iter() - .flat_map(v::tests) - .find(|t| v::string(t, "result") == "valid") - .map(|t| { - let source = format!("{file}/tcId={}", v::number(t, "tcId")); - (source, v::field(t, "password"), v::field(t, "salt")) - }) - .expect("a valid PBKDF2 test"); + let (source, password, salt) = select::pbkdf2_password_salt(&file, &vectors); for group in vectors.test_groups { for t in v::tests(&group) { let id = v::id(a, &file, t); @@ -39,7 +33,7 @@ pub fn run(p: &CryptoProvider, _: Options) { continue; } let expected = v::field(t, "dk"); - let outside = (iterations > 10_000_000 || v::number(t, "dkLen") > 1024) + let outside = (iterations > 10_000_000 || v::number(t, "dkLen") > MUST_SUPPORT_OUTPUT_LEN) .then_some((Algorithm::PasswordKdf(a), false)); if let Some(out) = c.outcome(&id, Expect::Success, outside, || { e.derive( @@ -61,18 +55,19 @@ pub fn run(p: &CryptoProvider, _: Options) { || e.derive(&password, &salt, iterations, length), ); } - // Controls: one iteration with a one-byte output is in the must-support set, and the RFC 8018 maximum is in the valid domain. + // Control for zero iterations and an empty output: one iteration with a one-byte output, in the must-support set. + // No control sits at the RFC 8018 maximum: deriving that much output could exhaust time or memory. let id = format!("{a:?}/bounds/1/1/{source}"); if let Some(out) = c.call(&id, Expect::Success, || e.derive(&password, &salt, 1, 1)) { c.check(&id, out.len() == 1, "output length"); } - if let Some(n) = maximum { - c.outcome( - &format!("{a:?}/bounds/1/{}/{source}", n - 1), - Expect::Success, - Some((Algorithm::PasswordKdf(a), false)), - || e.derive(&password, &salt, 1, n - 1).map(|_| ()), - ); + // Control at the edge of the must-support set: one byte above it is valid, so only success or Unsupported. + let length = MUST_SUPPORT_OUTPUT_LEN + 1; + let id = format!("{a:?}/bounds/1/{length}/{source}"); + if let Some(out) = c.outcome(&id, Expect::Success, Some((Algorithm::PasswordKdf(a), false)), || { + e.derive(&password, &salt, 1, length) + }) { + c.check(&id, out.len() == length, "output length"); } if a == PasswordKdfAlgorithm::Pbkdf2HmacSha1 { for r in v::pbkdf2_rfc() { diff --git a/picky-crypto-testsuite/src/differential/symmetric.rs b/picky-crypto-testsuite/src/differential/symmetric.rs index 0e7393dc..c03c827a 100644 --- a/picky-crypto-testsuite/src/differential/symmetric.rs +++ b/picky-crypto-testsuite/src/differential/symmetric.rs @@ -1,11 +1,13 @@ -//! Checks one provider alone can't make: derived lengths and empty-key HMAC tags without published answers, and AEAD sealed by one provider and opened by the other. +//! Checks one provider alone can't make: derived lengths, empty-key HMAC tags and boundary controls without published answers, and AEAD sealed by one provider and opened by the other. use picky_crypto::*; use proptest::prelude::*; use crate::algorithms::*; +use crate::areas::cipher::{cipher_records, transform}; +use crate::areas::password_kdf::MUST_SUPPORT_OUTPUT_LEN; use crate::harness::{Checks, Expect}; -use crate::published; +use crate::{published, select, vectors as v}; pub fn run(a: &CryptoProvider, b: &CryptoProvider) { let mut c = Checks::default(); @@ -84,9 +86,65 @@ pub fn run(a: &CryptoProvider, b: &CryptoProvider) { } } } + boundary_controls(&mut c, a, b); c.finish(); } +/// Compares the outputs of boundary controls that have no published answer, whenever both providers produce one. +fn boundary_controls(c: &mut Checks, a: &CryptoProvider, b: &CryptoProvider) { + fn compare(c: &mut Checks, id: &str, left: Result, right: Result) { + if let (Ok(left), Ok(right)) = (left, right) { + c.check(id, left.as_ref() == right.as_ref(), "the providers' outputs differ"); + } + } + let algorithm = CipherAlgorithm::Rc2Cbc; + if let (Ok(ae), Ok(be)) = (helpers::cipher(a, algorithm), helpers::cipher(b, algorithm)) { + let records = cipher_records(algorithm); + let (_, _, iv, data, _, _) = select::cbc_control(algorithm, &records); + for length in [1, 128] { + let (source, key) = select::message_of_length(length); + for protection in [Protection::Apply, Protection::Process] { + if !(ae.supports(protection) && be.supports(protection)) { + continue; + } + let id = format!("{algorithm:?}/{protection:?}/key length={length}/{source}/differential"); + compare( + c, + &id, + transform(ae, protection, &key, iv, data), + transform(be, protection, &key, iv, data), + ); + } + } + } + let algorithm = StreamCipherAlgorithm::Rc4; + if let (Ok(ae), Ok(be)) = ( + helpers::stream_cipher(a, algorithm), + helpers::stream_cipher(b, algorithm), + ) { + let apply = |e: &dyn StreamCipher, key: &[u8]| e.start(key)?.apply(key); + for (source, key) in [select::message_of_length(1), select::rc4_longest_key()] { + let id = format!("{algorithm:?}/key length={}/{source}/differential", key.len()); + compare(c, &id, apply(ae, &key), apply(be, &key)); + } + } + for (algorithm, sha) in PASSWORD_KDFS.into_iter().zip(SHAS) { + let (Ok(ae), Ok(be)) = (helpers::password_kdf(a, algorithm), helpers::password_kdf(b, algorithm)) else { + continue; + }; + let file = format!("pbkdf2_hmac{sha}_test.json"); + let (source, password, salt) = select::pbkdf2_password_salt(&file, &v::wycheproof(&file)); + for length in [1, MUST_SUPPORT_OUTPUT_LEN + 1] { + compare( + c, + &format!("{algorithm:?}/bounds/1/{length}/{source}/differential"), + ae.derive(&password, &salt, 1, length), + be.derive(&password, &salt, 1, length), + ); + } + } +} + fn fail(e: Error) -> TestCaseError { TestCaseError::fail(e.to_string()) } diff --git a/picky-crypto-testsuite/src/select.rs b/picky-crypto-testsuite/src/select.rs index e318d800..3b095856 100644 --- a/picky-crypto-testsuite/src/select.rs +++ b/picky-crypto-testsuite/src/select.rs @@ -221,6 +221,18 @@ pub fn message_of_length(len: usize) -> (String, Vec) { (format!("{file}/tcId={}/msg", v::number(t, "tcId")), v::field(t, "msg")) } +/// The password and salt of the first valid test in a Wycheproof PBKDF2 file, for bound checks and their controls. +pub fn pbkdf2_password_salt(file: &str, vectors: &v::Wycheproof) -> (String, Vec, Vec) { + let (_, t) = test(file, vectors, "valid PBKDF2 test", |_, t| { + v::string(t, "result") == "valid" + }); + ( + format!("{file}/tcId={}", v::number(t, "tcId")), + v::field(t, "password"), + v::field(t, "salt"), + ) +} + pub fn rsa_plaintext(limit: usize) -> (String, Vec) { required( "rfc/rfc1320.txt; rfc/rfc8032.txt", diff --git a/picky-crypto-testsuite/tests/differential.rs b/picky-crypto-testsuite/tests/differential.rs index 96a319b2..7e452017 100644 --- a/picky-crypto-testsuite/tests/differential.rs +++ b/picky-crypto-testsuite/tests/differential.rs @@ -79,3 +79,47 @@ fn empty_key_hmac_tags_must_agree(#[case] tag: u8, #[case] generates: bool, #[ca .unwrap_or_default(); assert_eq!(failure.contains("empty-key tags differ"), fails, "{failure}"); } + +/// RC4 whose keystream XORs every byte with `pad`. +struct FixedRc4(u8); +struct FixedKeystream(u8); +impl StreamCipherContext for FixedKeystream { + fn apply(&mut self, data: &[u8]) -> Result { + Ok(OutputBytes::new(Zeroizing::new( + data.iter().map(|b| b ^ self.0).collect(), + ))) + } +} +impl StreamCipher for FixedRc4 { + fn algorithm(&self) -> StreamCipherAlgorithm { + StreamCipherAlgorithm::Rc4 + } + fn fips(&self) -> bool { + false + } + fn start(&self, _: &[u8]) -> Result, Error> { + Ok(Box::new(FixedKeystream(self.0))) + } +} + +#[rstest::rstest] +#[case::same_keystreams(1, false)] +#[case::different_keystreams(2, true)] +fn boundary_control_outputs_must_agree(#[case] pad: u8, #[case] fails: bool) { + let provider = |pad| { + CryptoProvider::builder() + .with(Entry::StreamCipher(Arc::new(FixedRc4(pad)))) + .build() + .unwrap() + }; + let (a, b) = (provider(1), provider(pad)); + let failure = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| differential::symmetric::run(&a, &b))) + .err() + .and_then(|panic| panic.downcast_ref::().cloned()) + .unwrap_or_default(); + assert_eq!( + failure.contains("key length=1") && failure.contains("outputs differ"), + fails, + "{failure}" + ); +} From 876c5bc271deefbb44045cc4d25c2d0596ce6cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Tue, 13 Oct 2026 23:29:45 +0900 Subject: [PATCH 14/30] docs(picky-crypto): accept Unsupported outside the must-support set in controls Boundary controls may sit at a range limit below the must-support set, so they accept success or Unsupported outside it, not only above it. Section 12 also records that the error a backend's PBKDF2 size limit returns is not checked. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-testsuite/AGENTS.md | 3 ++- picky-crypto-testsuite/INTENT.md | 2 +- picky-crypto/CONTRACT.md | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/picky-crypto-testsuite/AGENTS.md b/picky-crypto-testsuite/AGENTS.md index bbafb870..c206f9a9 100644 --- a/picky-crypto-testsuite/AGENTS.md +++ b/picky-crypto-testsuite/AGENTS.md @@ -20,7 +20,8 @@ Sources, versions and checksums are in `vectors/manifest.toml`; `cargo xtask che - Structural inputs are not limited to empty, zero, one, or lengths at or beyond a limit. - A hand-written input that targets one check carries a control in the test asserting that every other check holds, so it can fail only for the intended reason. -- A control sits at a boundary the contract defines: the accepted value next to a rejected range limit, or just above the must-support set, where it accepts success or `Unsupported`. +- A control sits at a boundary the contract defines: the accepted value next to a rejected range limit, or the value just above the must-support set. + It accepts success, or `Unsupported` when its value is outside the must-support set. It asserts only contract properties, and differential runs compare its output between backends when it has no published answer. - Keep controls small: omit any control whose processing could exhaust a conforming backend's time or memory, and record the boundary it leaves unchecked as a known gap in `CONTRACT.md` section 12. - Prefer deriving such inputs from published ones. diff --git a/picky-crypto-testsuite/INTENT.md b/picky-crypto-testsuite/INTENT.md index 89955262..fa9bfe58 100644 --- a/picky-crypto-testsuite/INTENT.md +++ b/picky-crypto-testsuite/INTENT.md @@ -25,7 +25,7 @@ It is the executable form of the contract: a backend is correct when it passes t - A vector is an input paired with a computed expected output. Literal structural inputs whose expected outcome is a specified error (empty, zero, one, lengths at or beyond a limit) are not vectors and may be written by hand. A hand-written input never carries a computed expected output. - It expects a specified error, or, as a control at a boundary the contract defines (a range limit or the edge of the must-support set), acceptance (success, or `Unsupported` above the must-support set), checked only through properties the contract states. + It expects a specified error, or, as a control at a boundary the contract defines (a range limit or the edge of the must-support set), acceptance (success, or `Unsupported` outside the must-support set), checked only through properties the contract states. No control is so large that processing it could exhaust time or memory. ## Required behavior diff --git a/picky-crypto/CONTRACT.md b/picky-crypto/CONTRACT.md index 9f7610b5..a7b9a518 100644 --- a/picky-crypto/CONTRACT.md +++ b/picky-crypto/CONTRACT.md @@ -802,6 +802,7 @@ Each of these, except the PBKDF2 output above the maximum, has a control at the PBKDF2 also has a control at the edge of the must-support set, an output one byte above it, which succeeds or returns [`Error::Unsupported`], never [`Error::InvalidInput`]. In differential runs, the outputs of these controls are also compared between the two backends whenever both produce one and the control has no published answer. Known gap: an inward off-by-one at the PBKDF2 maximum output length is not detected, because a control at that length could exhaust a conforming backend's time or memory. +Known gap: the error a backend's PBKDF2 size limit returns is not checked, because reaching it needs an output too large for a control. Labels follow Wycheproof: From b34cc90146eb4e196784af37a4a8be7cece76cb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 14 Oct 2026 01:04:45 +0900 Subject: [PATCH 15/30] docs(picky-crypto): name capacity limits and padding-oracle handling A capacity limit is a backend's bound on a length or a numeric parameter, such as a maximum modulus size or public exponent, inside the valid domain and outside the must-support set; the contract and the RustCrypto backend use that one term. Section 7 states that decryption reports invalid padding as VerificationFailed and that protocols exposed to padding oracles apply their own countermeasure. The backend README documents RUSTSEC-2023-0071 and the state of the rsa crate's padding checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-rustcrypto/README.md | 3 +++ picky-crypto-rustcrypto/src/aead.rs | 2 +- picky-crypto-rustcrypto/src/cbc.rs | 4 ++-- picky-crypto-rustcrypto/src/pbkdf2.rs | 2 +- picky-crypto-rustcrypto/src/rc4.rs | 2 +- picky-crypto-rustcrypto/src/rsa.rs | 4 ++-- picky-crypto/CONTRACT.md | 16 +++++++++------- 7 files changed, 19 insertions(+), 14 deletions(-) diff --git a/picky-crypto-rustcrypto/README.md b/picky-crypto-rustcrypto/README.md index 883b9a69..7d64a571 100644 --- a/picky-crypto-rustcrypto/README.md +++ b/picky-crypto-rustcrypto/README.md @@ -37,6 +37,9 @@ Enabling ed25519-dalek's `legacy_compatibility` feature anywhere in a build weak Key-based KDFs and finite-field Diffie-Hellman are not provided; they come from other providers. RSA key generation draws a 256-bit seed from the operating-system generator and expands it with ChaCha20, so a generator failure is reported instead of panicking. RSA signing and decryption blind the private-key operation with the operating-system generator. +PKCS#1 v1.5 decryption is subject to RUSTSEC-2023-0071 (the Marvin timing attack), for which the rsa crate has no fixed release. +At rsa 0.10.0-rc.18 the padding checks use constant-time comparisons, except the minimum padding-length check, which the crate flags as possibly not constant time (`src/algorithms/pkcs1v15.rs`, `decrypt_inner`). +Invalid padding is reported as `VerificationFailed`, as the contract requires; a protocol exposed to padding oracles applies its own countermeasure. ECDSA signing mixes operating-system randomness into the RFC 6979 nonce as additional data. RSA public keys beyond the rsa crate's limits (a modulus above 8192 bits or a public exponent above 2^33 − 1) are unsupported. Private-key loading applies the same limits, although the rsa crate loads private keys of any modulus size. diff --git a/picky-crypto-rustcrypto/src/aead.rs b/picky-crypto-rustcrypto/src/aead.rs index c65bd486..f4e45d1d 100644 --- a/picky-crypto-rustcrypto/src/aead.rs +++ b/picky-crypto-rustcrypto/src/aead.rs @@ -7,7 +7,7 @@ use std::sync::Arc; struct Gcm(AeadAlgorithm); -/// This backend's size limit on `ciphertext || tag`, the contract's must-support maximum (2^31 − 1 bytes). +/// This backend's capacity limit on `ciphertext || tag`, the contract's must-support maximum (2^31 − 1 bytes). const SIZE_LIMIT: usize = i32::MAX as usize; const TAG_LEN: usize = 16; diff --git a/picky-crypto-rustcrypto/src/cbc.rs b/picky-crypto-rustcrypto/src/cbc.rs index 93fafb86..5fd677cd 100644 --- a/picky-crypto-rustcrypto/src/cbc.rs +++ b/picky-crypto-rustcrypto/src/cbc.rs @@ -35,10 +35,10 @@ impl Cbc { } } -/// This backend's size limit on data, the contract's must-support maximum for CBC data (2^31 − 1 bytes). +/// This backend's capacity limit on data, the contract's must-support maximum for CBC data (2^31 − 1 bytes). const DATA_SIZE_LIMIT: usize = i32::MAX as usize; -/// Checks fixed lengths first, then the size limit, so oversized data is never copied. +/// Checks fixed lengths first, then the capacity limit, so oversized data is never copied. fn check(algorithm: CipherAlgorithm, key_valid: bool, iv: &[u8], data: &[u8], block: usize) -> Result<(), Error> { if !key_valid { return Err(Error::InvalidKey); diff --git a/picky-crypto-rustcrypto/src/pbkdf2.rs b/picky-crypto-rustcrypto/src/pbkdf2.rs index 03af54b4..f752ce82 100644 --- a/picky-crypto-rustcrypto/src/pbkdf2.rs +++ b/picky-crypto-rustcrypto/src/pbkdf2.rs @@ -3,7 +3,7 @@ use picky_crypto::{Algorithm, Entry, Error, OutputBytes, PasswordKdf, PasswordKd use std::{marker::PhantomData, sync::Arc}; const MUST_SUPPORT_MAX_OUTPUT_LEN: usize = 1024; -/// This backend's size limit on derived output (2^31 − 1 bytes), far above the 1024-byte must-support maximum. +/// This backend's capacity limit on derived output (2^31 − 1 bytes), far above the 1024-byte must-support maximum. const OUTPUT_SIZE_LIMIT: usize = i32::MAX as usize; struct Pbkdf2(PasswordKdfAlgorithm, PhantomData); diff --git a/picky-crypto-rustcrypto/src/rc4.rs b/picky-crypto-rustcrypto/src/rc4.rs index d647c610..255c4b4b 100644 --- a/picky-crypto-rustcrypto/src/rc4.rs +++ b/picky-crypto-rustcrypto/src/rc4.rs @@ -30,7 +30,7 @@ impl StreamCipherContext for Context { return Err(Error::ProviderFailure); } let result = (|| { - // This backend's size limit on data, the contract's general must-support bound (2^31 − 1 bytes). + // This backend's capacity limit on data, the contract's general must-support bound (2^31 − 1 bytes). if data.len() > i32::MAX as usize { return Err(Error::Unsupported(picky_crypto::Algorithm::StreamCipher( StreamCipherAlgorithm::Rc4, diff --git a/picky-crypto-rustcrypto/src/rsa.rs b/picky-crypto-rustcrypto/src/rsa.rs index e1eb2ba8..ccfb6cf0 100644 --- a/picky-crypto-rustcrypto/src/rsa.rs +++ b/picky-crypto-rustcrypto/src/rsa.rs @@ -62,7 +62,7 @@ fn private_key_max_len() -> usize { } fn load_public_key(der: &[u8], algorithm: Algorithm) -> Result { - // Size limits take precedence over structure, so no oversized input reaches the library's decoders. + // Capacity limits take precedence over structure, so no oversized input reaches the library's decoders. if der.len() > PUBLIC_KEY_MAX_LEN { return Err(Error::Unsupported(algorithm)); } @@ -174,7 +174,7 @@ impl PrivateKeyLoader for Loader { let PrivateKeyMaterial::Pkcs8(der) = material else { return Err(Error::InvalidKey); }; - // Size limits take precedence over structure, so no oversized input reaches the library's decoders, whose + // Capacity limits take precedence over structure, so no oversized input reaches the library's decoders, whose // stack use grows with the input. if der.len() > private_key_max_len() { return Err(Error::Unsupported(Algorithm::PrivateKeyLoading(KeyType::Rsa))); diff --git a/picky-crypto/CONTRACT.md b/picky-crypto/CONTRACT.md index a7b9a518..ade1edde 100644 --- a/picky-crypto/CONTRACT.md +++ b/picky-crypto/CONTRACT.md @@ -56,13 +56,13 @@ These rules apply to every operation and are not repeated. 4. **Valid domain and must-support set.** Each operation defines a valid domain (inputs that are well-formed for the algorithm) and, within it, a must-support set. For an advertised operation: - - An input outside the valid domain returns [`Error::InvalidKey`] (key material) or [`Error::InvalidInput`] (anything else), except that a verifier on a library with a single opaque failure may return [`Error::VerificationFailed`] for an unusable public key, and a structurally malformed input within the bounds below but beyond a backend's size limit may return [`Error::Unsupported`] (section 4). + - An input outside the valid domain returns [`Error::InvalidKey`] (key material) or [`Error::InvalidInput`] (anything else), except that a verifier on a library with a single opaque failure may return [`Error::VerificationFailed`] for an unusable public key, and a structurally malformed input within the bounds below but beyond a backend's capacity limit may return [`Error::Unsupported`] (section 4). - An input inside the valid domain but outside the must-support set is either processed like an input in the must-support set or rejected with [`Error::Unsupported`] naming the algorithm (or, for such a verifier, [`Error::VerificationFailed`], section 4). For example, a backend may refuse RSA private keys other than 2048, 3072 or 4096-bit two-prime keys with equal-size primes, because some libraries accept only those. - An input in the must-support set never returns [`Error::Unsupported`], except for policy input narrowing (section 8.2). - Where an operation states no explicit domain for a length, any length is valid and lengths up to 2^31 − 1 bytes are in the must-support set. - Lengths fixed by the algorithm or the operation, and ranges defined by the algorithm's standard (for example RFC 8018's maximum derived-key length, the RC2 and RC4 key ranges), bound the valid domain: an input outside them returns the specified error on every backend, never [`Error::Unsupported`]. - These bounds are checked first, before allocation or any cryptographic processing, with overflow-safe arithmetic on [`usize`] arguments; size limits (section 4) come next, which may return [`Error::Unsupported`], and structure last, which returns the error the first item above assigns. + These bounds are checked first, before allocation or any cryptographic processing, with overflow-safe arithmetic on [`usize`] arguments; capacity limits (section 4) come next, which may return [`Error::Unsupported`], and structure last, which returns the error the first item above assigns. 5. **No RNG parameter.** Operations that need randomness (key generation, ephemeral keys, ECDSA nonces, RSA encryption padding, AES-GCM seal nonces) use the backend's own secure random generator. No generator is passed in, because some libraries accept only their own. @@ -144,11 +144,11 @@ The error set is closed: only the contract defines variants, never a backend, an When both [`Error::InvalidKey`] and [`Error::InvalidInput`] could apply, report [`Error::InvalidKey`]. Library key-validation failures map to [`Error::InvalidKey`], except when the library only rejects a well-formed key for its size or parameters, which maps to [`Error::Unsupported`] (rule 4). -Size limits take precedence: an input beyond a backend's size limit may be refused with [`Error::Unsupported`] before its structure is examined, whether or not it is also malformed. -A size limit is a backend's capacity bound inside the valid domain, above the must-support set. +A capacity limit is a backend's bound on a length or a numeric parameter (for example a maximum modulus size or public exponent), inside the valid domain and outside the must-support set. +Capacity limits take precedence: an input beyond a backend's capacity limit may be refused with [`Error::Unsupported`] before its structure is examined, whether or not it is also malformed. [`Error::ProviderFailure`] includes "PIN required", which callers therefore cannot distinguish from other device failures; this is a known limit of the closed set, to revisit if a consumer needs to prompt for a PIN. Some libraries report a single opaque failure for verification; a backend on such a library may report [`Error::VerificationFailed`] for a public key it cannot use, including an RSA modulus outside its range. -Conformance tests therefore expect [`Error::InvalidKey`] for a malformed public key within the backend's size limit, and accept either the result expected for a key in the must-support set or [`Error::Unsupported`] for a well-formed public key outside it; for a backend on such a library they also accept [`Error::VerificationFailed`] in both cases. +Conformance tests therefore expect [`Error::InvalidKey`] for a malformed public key within the backend's capacity limits, and accept either the result expected for a key in the must-support set or [`Error::Unsupported`] for a well-formed public key outside it; for a backend on such a library they also accept [`Error::VerificationFailed`] in both cases. For a public key in the must-support set with a wrong signature, they expect only [`Error::VerificationFailed`]. ## 5. Encodings @@ -574,6 +574,8 @@ Valid domain and must-support set: RSASSA-PKCS1-v1_5 signatures always encode NULL DigestInfo parameters (section 6.9). +Decryption reports invalid padding as [`Error::VerificationFailed`]; a protocol exposed to padding oracles applies its own countermeasure (for example RFC 7516 §11.5: substitute a random key and continue). + RSA private-key results are checked: every signature or plaintext is the correct result under the key's public components (`n`, `e`), or the operation fails. A backend never outputs a signature or plaintext computed from inconsistent key components, because a single faulty CRT signature reveals a prime factor of `n`. Handling an RSA private key with inconsistent or non-distinct components (wrong `dP`, `dQ`, `qInv` or `d`, or `p` = `q`) is implementation-defined. @@ -794,7 +796,7 @@ Before a vector's label is applied, its eligibility is decided by the rules abov - MAC, cipher, AEAD and key-wrap vectors apply only to supported protections; - for a vector whose inputs are in the operation's must-support set, the label is applied as below; - for a vector whose inputs are inside the valid domain but outside the must-support set (for example a 1024-bit RSA key), [`Error::Unsupported`] is also accepted, and so is [`Error::VerificationFailed`] from a verifier on a library with a single opaque failure (section 4); any other result must match the label; -- for a vector whose inputs are within the fixed lengths and standard ranges of rule 4 but both beyond the backend's size limit and malformed, the allowed outcomes are [`Error::Unsupported`] or the error section 4 assigns to the malformed input ([`Error::InvalidKey`] for key material, [`Error::InvalidInput`] otherwise, or [`Error::VerificationFailed`] from a verifier on a library with a single opaque failure); well-formed inputs beyond the size limit follow the previous item. +- for a vector whose inputs are within the fixed lengths and standard ranges of rule 4 but both beyond a backend's capacity limit and malformed, the allowed outcomes are [`Error::Unsupported`] or the error section 4 assigns to the malformed input ([`Error::InvalidKey`] for private and public keys alike, [`Error::InvalidInput`] for other inputs, or [`Error::VerificationFailed`] from a verifier on a library with a single opaque failure); well-formed inputs beyond a capacity limit follow the previous item. Inputs just outside a length the algorithm or the operation fixes, or a range the algorithm's standard defines, expect the specified error and never [`Error::Unsupported`] (rule 4). They are hand-written structural inputs, and the suite checks at least RC2 keys of 0 and 129 bytes, RC4 keys of 0 and 257 bytes, and PBKDF2 with zero iterations, an empty output or an output one byte above the RFC 8018 maximum where [`usize`] can represent it. @@ -802,7 +804,7 @@ Each of these, except the PBKDF2 output above the maximum, has a control at the PBKDF2 also has a control at the edge of the must-support set, an output one byte above it, which succeeds or returns [`Error::Unsupported`], never [`Error::InvalidInput`]. In differential runs, the outputs of these controls are also compared between the two backends whenever both produce one and the control has no published answer. Known gap: an inward off-by-one at the PBKDF2 maximum output length is not detected, because a control at that length could exhaust a conforming backend's time or memory. -Known gap: the error a backend's PBKDF2 size limit returns is not checked, because reaching it needs an output too large for a control. +Known gap: the error a backend's PBKDF2 capacity limit returns is not checked, because reaching it needs an output too large for a control. Labels follow Wycheproof: From 57ea81b27dd16adfa0fa08917ac768f93a53b47a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 14 Oct 2026 02:22:32 +0900 Subject: [PATCH 16/30] docs(picky-crypto): drop dependency versions from the RustCrypto README Versions live in Cargo.lock, so the README names the library crates only. Library source paths in comments use forward slashes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-rustcrypto/README.md | 24 +++++++++++------------ picky-crypto-rustcrypto/src/curve25519.rs | 10 +++++----- picky-crypto-rustcrypto/src/ec.rs | 8 ++++---- picky-crypto-rustcrypto/src/rsa.rs | 24 +++++++++++------------ 4 files changed, 33 insertions(+), 33 deletions(-) diff --git a/picky-crypto-rustcrypto/README.md b/picky-crypto-rustcrypto/README.md index 7d64a571..94009a51 100644 --- a/picky-crypto-rustcrypto/README.md +++ b/picky-crypto-rustcrypto/README.md @@ -9,24 +9,24 @@ Parsing and encoding formats need no provider; only code that performs cryptogra ## Features and the library -The library consists of the crates below, at the versions locked by the workspace. +The library consists of the crates below; `Cargo.lock` records their versions. Dependencies needed internally do not enable additional provider entries. MAC, CBC, AES-GCM and AES-KW entries support both applying and processing protection. | Feature | Provider entries | Library crates | |---|---|---| -| `digest` | SHA-1/224/256/384/512, SHA3-384/512; HMAC-SHA-1/224/256/384/512; PBKDF2 with those HMACs | sha1 0.11.0, sha2 0.11.0, sha3 0.12.0, digest 0.11.3, hmac 0.13.0, pbkdf2 0.13.0 | -| `aes` | AES-128/192/256-CBC without padding | aes 0.9.2, cbc 0.2.1, cipher 0.5.2 | -| `aead` | AES-128/192/256-GCM; secure random | aes-gcm 0.11.1, aes 0.9.2, getrandom 0.4.3 | -| `key-wrap` | AES-128/192/256-KW | aes-kw 0.3.1, aes 0.9.2 | -| `rsa` | PKCS#1 v1.5 verification with MD5, SHA-1/224/256/384/512 and SHA3-384/512; PKCS#1 v1.5 and OAEP-SHA-1/256 encryption; RSA private-key loading and 2048/3072/4096-bit generation; secure random | rsa 0.10.0-rc.18, md-5 0.11.0, sha1 0.11.0, sha2 0.11.0, sha3 0.12.0, getrandom 0.4.3, chacha20 0.10.2 | -| `nist-ec` | P-256/SHA-256, P-384/SHA-384 and P-521/SHA-512 ECDSA verification; ECDH, private-key loading and generation for those curves; secure random | p256/p384/p521 0.14.0, sha2 0.11.0, hmac 0.13.0, getrandom 0.4.3 | -| `curve25519` | Ed25519 verification, private-key loading and generation; X25519 ephemeral agreement and private-key loading; secure random | ed25519-dalek/ed25519/x25519-dalek 3.0.0, sha2 0.11.0, getrandom 0.4.3 | -| `legacy` | MD4, MD5, RC4, 3DES-EDE3-CBC and RC2-CBC | md4/md-5 0.11.0, digest 0.11.3, rc4 0.2.0, des/rc2 0.9.0, cbc 0.2.1, cipher 0.5.2 | +| `digest` | SHA-1/224/256/384/512, SHA3-384/512; HMAC-SHA-1/224/256/384/512; PBKDF2 with those HMACs | sha1, sha2, sha3, digest, hmac, pbkdf2 | +| `aes` | AES-128/192/256-CBC without padding | aes, cbc, cipher | +| `aead` | AES-128/192/256-GCM; secure random | aes-gcm, aes, getrandom | +| `key-wrap` | AES-128/192/256-KW | aes-kw, aes | +| `rsa` | PKCS#1 v1.5 verification with MD5, SHA-1/224/256/384/512 and SHA3-384/512; PKCS#1 v1.5 and OAEP-SHA-1/256 encryption; RSA private-key loading and 2048/3072/4096-bit generation; secure random | rsa, md-5, sha1, sha2, sha3, getrandom, chacha20 | +| `nist-ec` | P-256/SHA-256, P-384/SHA-384 and P-521/SHA-512 ECDSA verification; ECDH, private-key loading and generation for those curves; secure random | p256, p384, p521, sha2, hmac, getrandom | +| `curve25519` | Ed25519 verification, private-key loading and generation; X25519 ephemeral agreement and private-key loading; secure random | ed25519-dalek, ed25519, x25519-dalek, sha2, getrandom | +| `legacy` | MD4, MD5, RC4, 3DES-EDE3-CBC and RC2-CBC | md4, md-5, digest, rc4, des, rc2, cbc, cipher | | `all` | Every family above | Every dependency above | -The library also includes these support crates, some reached through re-exports: aead 0.6.1, crypto-common 0.2.2, hybrid-array 0.4.15, typenum 1.20.1, crypto-bigint 0.7.5, elliptic-curve 0.14.1, ecdsa 0.17.0, signature 3.0.0, curve25519-dalek 5.0.0, pkcs8 0.11.0, spki 0.8.0, sec1 0.8.1, der 0.8.2, const-oid 0.10.2, rand_core 0.10.1 and zeroize 1.9.0. -PKCS#1 encoding is reached through rsa's pkcs1 0.8.0-rc.4 re-export. +The library also includes these support crates, some reached through re-exports: aead, crypto-common, hybrid-array, typenum, crypto-bigint, elliptic-curve, ecdsa, signature, curve25519-dalek, pkcs8, spki, sec1, der, const-oid, rand_core and zeroize. +PKCS#1 encoding is reached through rsa's pkcs1 re-export. Secret cleanup features are enabled wherever the library offers them, and adapter-owned secret copies and returned buffers use zeroizing storage. Loaded RSA keys sign with every listed RSA signature algorithm, decrypt with every listed RSA encryption algorithm and export their public key. @@ -38,7 +38,7 @@ Key-based KDFs and finite-field Diffie-Hellman are not provided; they come from RSA key generation draws a 256-bit seed from the operating-system generator and expands it with ChaCha20, so a generator failure is reported instead of panicking. RSA signing and decryption blind the private-key operation with the operating-system generator. PKCS#1 v1.5 decryption is subject to RUSTSEC-2023-0071 (the Marvin timing attack), for which the rsa crate has no fixed release. -At rsa 0.10.0-rc.18 the padding checks use constant-time comparisons, except the minimum padding-length check, which the crate flags as possibly not constant time (`src/algorithms/pkcs1v15.rs`, `decrypt_inner`). +In the locked rsa version the padding checks use constant-time comparisons, except the minimum padding-length check, which the crate flags as possibly not constant time (`src/algorithms/pkcs1v15.rs`, `decrypt_inner`). Invalid padding is reported as `VerificationFailed`, as the contract requires; a protocol exposed to padding oracles applies its own countermeasure. ECDSA signing mixes operating-system randomness into the RFC 6979 nonce as additional data. RSA public keys beyond the rsa crate's limits (a modulus above 8192 bits or a public exponent above 2^33 − 1) are unsupported. diff --git a/picky-crypto-rustcrypto/src/curve25519.rs b/picky-crypto-rustcrypto/src/curve25519.rs index 111d62b1..041e1242 100644 --- a/picky-crypto-rustcrypto/src/curve25519.rs +++ b/picky-crypto-rustcrypto/src/curve25519.rs @@ -27,7 +27,7 @@ impl SignatureVerifier for Verify { fn verify(&self, public: PublicKey<'_>, message: &[u8], signature: &[u8]) -> Result<(), Error> { let public = public.0.try_into().map_err(|_| Error::InvalidKey)?; let key = VerifyingKey::from_bytes(public).map_err(|_| Error::InvalidKey)?; - // The library accepts non-canonical A encodings (ZIP-215, ed25519-dalek 3.0.0, src\verifying.rs:172–182), + // The library accepts non-canonical A encodings (ZIP-215, ed25519-dalek 3.0.0, src/verifying.rs:172–182), // so the adapter compares the library's canonical recompression with the input. if key.to_edwards().compress().as_bytes() != public { return Err(Error::InvalidKey); @@ -49,12 +49,12 @@ impl PrivateKeyLoader for EdLoader { }; let info = PrivateKeyInfoRef::from_der(der).map_err(|_| Error::InvalidKey)?; // Ed25519's decoder ignores non-aligned public keys and rejects other lengths - // (ed25519 3.0.0, src\pkcs8.rs:180–185). - // as_bytes rejects unused bits (der 0.8.2, src\asn1\bit_string.rs:121–127). + // (ed25519 3.0.0, src/pkcs8.rs:180–185). + // as_bytes rejects unused bits (der 0.8.2, src/asn1/bit_string.rs:121–127). if info.public_key.is_some_and(|public| public.as_bytes().is_none()) { return Err(Error::InvalidKey); } - // The library checks that an embedded public key matches (ed25519-dalek 3.0.0, src\signing.rs:748–759). + // The library checks that an embedded public key matches (ed25519-dalek 3.0.0, src/signing.rs:748–759). let key = SigningKey::try_from(info).map_err(|_| Error::InvalidKey)?; Ok(Box::new(EdKey(key))) } @@ -140,7 +140,7 @@ impl KeyAgreement for XAgreement { } fn generate_ephemeral(&self) -> Result, Error> { // Mirrors x25519-dalek 3.0.0's EphemeralSecret::random(): 32 bytes from getrandom, clamping left to the library. - // Returns random-generator failures instead of panicking (src\x25519.rs:96–101). + // Returns random-generator failures instead of panicking (src/x25519.rs:96–101). let mut scalar = Zeroizing::new([0u8; 32]); getrandom::fill(&mut *scalar).map_err(|_| Error::ProviderFailure)?; Ok(Box::new(XKey(x25519_dalek::StaticSecret::from(*scalar)))) diff --git a/picky-crypto-rustcrypto/src/ec.rs b/picky-crypto-rustcrypto/src/ec.rs index c58a46d4..33628e23 100644 --- a/picky-crypto-rustcrypto/src/ec.rs +++ b/picky-crypto-rustcrypto/src/ec.rs @@ -42,7 +42,7 @@ macro_rules! curve { fn verify(&self, public: PublicKey<'_>, message: &[u8], signature: &[u8]) -> Result<(), Error> { uncompressed(public.0, $point_len, Error::InvalidKey)?; let key = VerifyingKey::from_sec1_bytes(public.0).map_err(|_| Error::InvalidKey)?; - // Rejects lengths other than 2 × the field size (ecdsa 0.17.0, src\lib.rs:212–216). + // Rejects lengths other than 2 × the field size (ecdsa 0.17.0, src/lib.rs:212–216). let signature = Signature::from_slice(signature).map_err(|_| Error::VerificationFailed)?; key.verify(message, &signature) .map_err(|_| Error::VerificationFailed) @@ -69,12 +69,12 @@ macro_rules! curve { } let inner = sec1::EcPrivateKey::from_der(info.private_key.as_bytes()).map_err(|_| Error::InvalidKey)?; - // SecretKey::from_slice zero-pads short scalars (elliptic-curve 0.14.1, src\secret_key.rs:163–173). + // SecretKey::from_slice zero-pads short scalars (elliptic-curve 0.14.1, src/secret_key.rs:163–173). if inner.private_key.len() != $signature_len / 2 { return Err(Error::InvalidKey); } // The library checks the curve OIDs and that an embedded public key matches - // (elliptic-curve 0.14.1, src\secret_key\pkcs8.rs:53–59; src\secret_key.rs:465–484). + // (elliptic-curve 0.14.1, src/secret_key/pkcs8.rs:53–59; src/secret_key.rs:465–484). let key = SecretKey::try_from(info).map_err(|_| Error::InvalidKey)?; Ok(Box::new(Key(key))) } @@ -103,7 +103,7 @@ macro_rules! curve { } let signing = SigningKey::from(&self.0); // Hedged RFC 6979 nonce: random bytes enter as additional data, and an RNG failure is - // the only error on this path (ecdsa 0.17.0, src\signing.rs:209–232, 242–265). + // the only error on this path (ecdsa 0.17.0, src/signing.rs:209–232, 242–265). let signature: Signature = signing .try_sign_with_rng(&mut getrandom::SysRng, message) .map_err(|_| Error::ProviderFailure)?; diff --git a/picky-crypto-rustcrypto/src/rsa.rs b/picky-crypto-rustcrypto/src/rsa.rs index ccfb6cf0..1689b433 100644 --- a/picky-crypto-rustcrypto/src/rsa.rs +++ b/picky-crypto-rustcrypto/src/rsa.rs @@ -50,7 +50,7 @@ const fn der_integer_len(bits: usize) -> usize { const MODULUS_LEN: usize = der_integer_len(RsaPublicKey::MAX_SIZE); const EXPONENT_LEN: usize = der_integer_len((u64::BITS - RsaPublicKey::MAX_PUB_EXPONENT.leading_zeros()) as usize); -/// Longest PKCS#1 `RSAPublicKey` within the library's limits (rsa 0.10.0-rc.18, src\key.rs:216–222). +/// Longest PKCS#1 `RSAPublicKey` within the library's limits (rsa 0.10.0-rc.18, src/key.rs:216–222). const PUBLIC_KEY_MAX_LEN: usize = der_len(MODULUS_LEN + EXPONENT_LEN); /// Longest PKCS#8 RSA private key within the library's limits: version, n, e, then d, p, q, dP, dQ and qInv, each @@ -73,7 +73,7 @@ fn load_public_key(der: &[u8], algorithm: Algorithm) -> Result Error::Unsupported(algorithm), _ => Error::InvalidKey, @@ -131,7 +131,7 @@ fn encryption_supported(algorithm: AsymmetricEncryptionAlgorithm) -> bool { } fn error(error: rsa::Error) -> Error { // Decryption re-encrypts its result and reports a mismatch as Internal, mapped to InvalidKey - // (rsa 0.10.0-rc.18, src\algorithms\rsa.rs:156–172; src\pkcs1v15.rs:180; src\oaep.rs:279). + // (rsa 0.10.0-rc.18, src/algorithms/rsa.rs:156–172; src/pkcs1v15.rs:180; src/oaep.rs:279). match error { rsa::Error::Rng => Error::ProviderFailure, rsa::Error::MessageTooLong => Error::InvalidInput, @@ -140,7 +140,7 @@ fn error(error: rsa::Error) -> Error { } } fn encrypt(key: &RsaPublicKey, plaintext: &[u8], padding: P) -> Result { - // Native ciphertext output is modulus-sized (rsa 0.10.0-rc.18, src\pkcs1v15.rs:159; src\oaep.rs:218). + // Native ciphertext output is modulus-sized (rsa 0.10.0-rc.18, src/pkcs1v15.rs:159; src/oaep.rs:218). padding .encrypt(&mut getrandom::SysRng, key, plaintext) .map(|output| OutputBytes::new(Zeroizing::new(output))) @@ -191,9 +191,9 @@ impl PrivateKeyLoader for Loader { } let parsed = rsa::pkcs1::RsaPrivateKey::from_der(info.private_key.as_bytes()).map_err(|_| Error::InvalidKey)?; // The library's public-key limits apply before decoding: it loads private keys of any modulus size - // (rsa 0.10.0-rc.18, src\key.rs:706–710) and reports an oversized exponent only as a malformed key - // (src\encoding.rs:66; src\key.rs:723–729). `as_bytes` strips the sign octet, so 8192 bits is 1024 bytes - // (der 0.8.2, src\asn1\integer\uint.rs:351–365,406–416). + // (rsa 0.10.0-rc.18, src/key.rs:706–710) and reports an oversized exponent only as a malformed key + // (src/encoding.rs:66; src/key.rs:723–729). `as_bytes` strips the sign octet, so 8192 bits is 1024 bytes + // (der 0.8.2, src/asn1/integer/uint.rs:351–365,406–416). let exponent = parsed.public_exponent.as_bytes(); if parsed.modulus.as_bytes().len() > RsaPublicKey::MAX_SIZE / 8 || uint(exponent, precision(exponent)?)? > BoxedUint::from(RsaPublicKey::MAX_PUB_EXPONENT) @@ -225,8 +225,8 @@ fn generate_pkcs8(bits: usize) -> Result { use rsa::rand_core::SeedableRng; let mut seed = Zeroizing::new([0u8; 32]); getrandom::fill(&mut *seed).map_err(|_| Error::ProviderFailure)?; - // Infallible seeded RNG that wipes its buffer and state on drop (chacha20 0.10.2, src\rng.rs:94–98; - // src\lib.rs:306–310). + // Infallible seeded RNG that wipes its buffer and state on drop (chacha20 0.10.2, src/rng.rs:94–98; + // src/lib.rs:306–310). let mut rng = chacha20::ChaCha20Rng::from_seed(*seed); let key = RsaPrivateKey::new_with_exp(&mut rng, bits, BoxedUint::from(65537u64)).map_err(|_| Error::ProviderFailure)?; @@ -234,9 +234,9 @@ fn generate_pkcs8(bits: usize) -> Result { crate::util::output(document.as_bytes()) } fn signing_error(error: rsa::Error, algorithm: SignatureAlgorithm) -> Error { - // An undersized modulus is MessageTooLong (rsa 0.10.0-rc.18, src\algorithms\pkcs1v15.rs:120–125). + // An undersized modulus is MessageTooLong (rsa 0.10.0-rc.18, src/algorithms/pkcs1v15.rs:120–125). // Signing checks its result like decryption and reports a mismatch as Internal, mapped to InvalidKey - // (src\algorithms\rsa.rs:156–172; src\pkcs1v15.rs:209). + // (src/algorithms/rsa.rs:156–172; src/pkcs1v15.rs:209). match error { rsa::Error::MessageTooLong => Error::Unsupported(Algorithm::Signature(algorithm)), rsa::Error::Rng => Error::ProviderFailure, @@ -251,7 +251,7 @@ fn sign( ) -> Result { let digest = Zeroizing::new(D::digest(message)); // The scheme blinds with a fallible generator, unlike `sign_with_rng`, and pads to size(), not whole limbs - // (rsa 0.10.0-rc.18, src\key.rs:662; src\traits\padding.rs:35–40; src\pkcs1v15.rs:200–209). + // (rsa 0.10.0-rc.18, src/key.rs:662; src/traits/padding.rs:35–40; src/pkcs1v15.rs:200–209). rsa::Pkcs1v15Sign::new::() .sign(Some(&mut getrandom::SysRng), key, &digest) .map(|signature| OutputBytes::new(Zeroizing::new(signature))) From 80d11e3f42fe4f22fbcfef4a6d7901cf85bc494d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 14 Oct 2026 02:38:41 +0900 Subject: [PATCH 17/30] test(picky-crypto): cover every boundary-control comparison The differential self-test now drives mock RC4, RC2-CBC and PBKDF2 entries, so a comparison branch that is miswired, for example comparing a provider with itself, fails. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-testsuite/tests/differential.rs | 67 ++++++++++++++++---- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/picky-crypto-testsuite/tests/differential.rs b/picky-crypto-testsuite/tests/differential.rs index 7e452017..369290e6 100644 --- a/picky-crypto-testsuite/tests/differential.rs +++ b/picky-crypto-testsuite/tests/differential.rs @@ -102,24 +102,69 @@ impl StreamCipher for FixedRc4 { } } +/// RC2-CBC that XORs every byte with `pad`, in both directions. +struct FixedRc2(u8); +impl Cipher for FixedRc2 { + fn algorithm(&self) -> CipherAlgorithm { + CipherAlgorithm::Rc2Cbc + } + fn fips(&self) -> bool { + false + } + fn supports(&self, _: Protection) -> bool { + true + } + fn encrypt(&self, _: &[u8], _: &[u8], data: &[u8]) -> Result { + Ok(OutputBytes::new(Zeroizing::new( + data.iter().map(|b| b ^ self.0).collect(), + ))) + } + fn decrypt(&self, key: &[u8], iv: &[u8], data: &[u8]) -> Result { + self.encrypt(key, iv, data) + } +} + +/// PBKDF2-HMAC-SHA1 that returns `output_len` copies of `pad` above 64 bytes, beyond the published-input property's lengths, and zeros otherwise. +struct FixedPbkdf2(u8); +impl PasswordKdf for FixedPbkdf2 { + fn algorithm(&self) -> PasswordKdfAlgorithm { + PasswordKdfAlgorithm::Pbkdf2HmacSha1 + } + fn fips(&self) -> bool { + false + } + fn derive(&self, _: &[u8], _: &[u8], _: u32, output_len: usize) -> Result { + let byte = if output_len > 64 { self.0 } else { 0 }; + Ok(OutputBytes::new(Zeroizing::new(vec![byte; output_len]))) + } +} + +/// Each boundary-control comparison fails exactly when the two providers' entries for it disagree. #[rstest::rstest] -#[case::same_keystreams(1, false)] -#[case::different_keystreams(2, true)] -fn boundary_control_outputs_must_agree(#[case] pad: u8, #[case] fails: bool) { - let provider = |pad| { +#[case::all_agree([1, 1, 1], None)] +#[case::rc4_differs([2, 1, 1], Some("Rc4/key length="))] +#[case::rc2_differs([1, 2, 1], Some("Rc2Cbc/"))] +#[case::pbkdf2_differs([1, 1, 2], Some("Pbkdf2HmacSha1/bounds/1/"))] +fn boundary_control_outputs_must_agree(#[case] pads: [u8; 3], #[case] differing: Option<&str>) { + let provider = |[rc4, rc2, pbkdf2]: [u8; 3]| { CryptoProvider::builder() - .with(Entry::StreamCipher(Arc::new(FixedRc4(pad)))) + .with(Entry::StreamCipher(Arc::new(FixedRc4(rc4)))) + .with(Entry::Cipher(Arc::new(FixedRc2(rc2)))) + .with(Entry::PasswordKdf(Arc::new(FixedPbkdf2(pbkdf2)))) .build() .unwrap() }; - let (a, b) = (provider(1), provider(pad)); + let (a, b) = (provider([1, 1, 1]), provider(pads)); let failure = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| differential::symmetric::run(&a, &b))) .err() .and_then(|panic| panic.downcast_ref::().cloned()) .unwrap_or_default(); - assert_eq!( - failure.contains("key length=1") && failure.contains("outputs differ"), - fails, - "{failure}" - ); + let differ = |marker: &str| { + failure + .lines() + .any(|line| line.contains(marker) && line.contains("/differential") && line.contains("outputs differ")) + }; + for marker in ["Rc4/key length=", "Rc2Cbc/", "Pbkdf2HmacSha1/bounds/1/"] { + assert_eq!(differ(marker), differing == Some(marker), "{marker}: {failure}"); + } } From ba9887519d5fe0df72d231e0d33593a7d9160266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 14 Oct 2026 21:28:45 +0900 Subject: [PATCH 18/30] fix(picky-crypto): check the Ed25519 S range in the adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 8032 §5.1.7 requires S < L. ed25519-dalek weakens its own check when any crate in a build enables its `legacy_compatibility` feature, and the cargo-deny ban protects only this workspace. A CI step runs the signature conformance area with that feature enabled, so the adapter check is exercised on the Wycheproof S + L signatures. The MSRV matrix now checks the backend with every feature. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 13 +++++++++++-- Cargo.lock | 1 + picky-crypto-rustcrypto/Cargo.toml | 3 ++- picky-crypto-rustcrypto/README.md | 3 ++- picky-crypto-rustcrypto/src/curve25519.rs | 8 ++++++++ 5 files changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 000e5eee..32044817 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,7 +103,7 @@ jobs: strategy: fail-fast: false matrix: - crate: [ picky-asn1, picky-asn1-der, picky-asn1-x509, picky, picky-crypto ] + crate: [ picky-asn1, picky-asn1-der, picky-asn1-x509, picky, picky-crypto, picky-crypto-rustcrypto ] include: - crate: picky-asn1 msvc: "1.85" @@ -115,6 +115,10 @@ jobs: msvc: "1.85" - crate: picky-crypto msvc: "1.85" + - crate: picky-crypto-rustcrypto + msvc: "1.85" + # Without features the crate builds only an empty provider. + args: --features all steps: - uses: actions/checkout@v6 @@ -125,7 +129,7 @@ jobs: curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain ${{ matrix.msvc }} - name: cargo check ${{ matrix.crate }} - run: cargo +${{ matrix.msvc }} check -p ${{ matrix.crate }} + run: cargo +${{ matrix.msvc }} check -p ${{ matrix.crate }} ${{ matrix.args }} dotnet-tests: name: .NET binding tests @@ -262,6 +266,11 @@ jobs: - name: Check banned features run: cargo deny --manifest-path Cargo.toml --workspace --all-features --config picky-crypto-rustcrypto/deny.toml check bans + # ed25519-dalek's `legacy_compatibility` feature weakens the library's S < L check, + # so this run shows that the adapter's own check rejects the Wycheproof S + L signatures. + - name: Check Ed25519 S range with legacy_compatibility + run: cargo test --locked -p picky-crypto-conformance -p picky-crypto-rustcrypto --features picky-crypto-conformance/rustcrypto,ed25519-dalek/legacy_compatibility --test conformance -- rustcrypto::signature + success: name: Success if: ${{ always() }} diff --git a/Cargo.lock b/Cargo.lock index bb33e93a..46166f88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1408,6 +1408,7 @@ dependencies = [ "chacha20", "cipher", "crypto-common", + "curve25519-dalek", "des", "digest", "ed25519", diff --git a/picky-crypto-rustcrypto/Cargo.toml b/picky-crypto-rustcrypto/Cargo.toml index 7d66f920..768c944b 100644 --- a/picky-crypto-rustcrypto/Cargo.toml +++ b/picky-crypto-rustcrypto/Cargo.toml @@ -19,7 +19,7 @@ aead = ["dep:aes-gcm", "dep:aes", "dep:getrandom", "dep:crypto-common"] key-wrap = ["dep:aes-kw", "dep:aes"] rsa = ["dep:rsa", "dep:md-5", "dep:sha1", "dep:sha2", "dep:sha3", "dep:getrandom", "dep:chacha20", "dep:crypto-common"] nist-ec = ["dep:p256", "dep:p384", "dep:p521", "dep:sha2", "dep:hmac", "dep:getrandom", "dep:crypto-common", "dep:sec1"] -curve25519 = ["dep:ed25519-dalek", "dep:ed25519", "dep:x25519-dalek", "dep:sha2", "dep:getrandom", "dep:crypto-common"] +curve25519 = ["dep:ed25519-dalek", "dep:ed25519", "dep:x25519-dalek", "dep:curve25519-dalek", "dep:sha2", "dep:getrandom", "dep:crypto-common"] legacy = ["dep:md4", "dep:md-5", "dep:rc4", "dep:des", "dep:rc2", "dep:cbc", "dep:cipher", "dep:digest"] all = ["digest", "aes", "aead", "key-wrap", "rsa", "nist-ec", "curve25519", "legacy"] @@ -51,6 +51,7 @@ p384 = { version = "0.14.0", optional = true, default-features = false, features p521 = { version = "0.14.0", optional = true, default-features = false, features = ["ecdsa", "ecdh", "pkcs8", "alloc", "precomputed-tables", "std"] } ed25519-dalek = { version = "3.0.0", optional = true, default-features = false, features = ["alloc", "digest", "fast", "pkcs8", "rand_core", "zeroize"] } ed25519 = { version = "3.0.0", optional = true, default-features = false, features = ["pkcs8", "zeroize"] } +curve25519-dalek = { version = "5.0.0", optional = true, default-features = false } x25519-dalek = { version = "3.0.0", optional = true, default-features = false, features = ["precomputed-tables", "static_secrets", "zeroize"] } getrandom = { version = "0.4.3", optional = true, features = ["sys_rng"] } diff --git a/picky-crypto-rustcrypto/README.md b/picky-crypto-rustcrypto/README.md index 94009a51..d0b67c79 100644 --- a/picky-crypto-rustcrypto/README.md +++ b/picky-crypto-rustcrypto/README.md @@ -32,7 +32,8 @@ Secret cleanup features are enabled wherever the library offers them, and adapte Loaded RSA keys sign with every listed RSA signature algorithm, decrypt with every listed RSA encryption algorithm and export their public key. Loaded EC keys sign and agree on their own curve and export their public key. Loaded Ed25519 keys sign and export their public key; loaded X25519 keys agree and export their public key. -Enabling ed25519-dalek's `legacy_compatibility` feature anywhere in a build weakens this backend's Ed25519 signature `S` range check, because Cargo unifies features; this workspace bans it with cargo-deny. +Enabling ed25519-dalek's `legacy_compatibility` feature anywhere in a build weakens the library's Ed25519 signature `S` range check, because Cargo unifies features. +The adapter therefore checks `S < L` itself (RFC 8032 §5.1.7), and this workspace bans the feature with cargo-deny. Key-based KDFs and finite-field Diffie-Hellman are not provided; they come from other providers. RSA key generation draws a 256-bit seed from the operating-system generator and expands it with ChaCha20, so a generator failure is reported instead of panicking. diff --git a/picky-crypto-rustcrypto/src/curve25519.rs b/picky-crypto-rustcrypto/src/curve25519.rs index 041e1242..b3d80644 100644 --- a/picky-crypto-rustcrypto/src/curve25519.rs +++ b/picky-crypto-rustcrypto/src/curve25519.rs @@ -33,6 +33,14 @@ impl SignatureVerifier for Verify { return Err(Error::InvalidKey); } let signature = Signature::from_slice(signature).map_err(|_| Error::VerificationFailed)?; + // RFC 8032 §5.1.7 requires S < L. The library's check weakens when any crate in the build enables + // ed25519-dalek's `legacy_compatibility` feature, so the adapter checks it itself. + if curve25519_dalek::Scalar::from_canonical_bytes(*signature.s_bytes()) + .is_none() + .into() + { + return Err(Error::VerificationFailed); + } key.verify(message, &signature).map_err(|_| Error::VerificationFailed) } } From 3edb3e8abea8bd3711d5b9752a3f14a5919838e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 14 Oct 2026 21:28:45 +0900 Subject: [PATCH 19/30] docs(picky-crypto): note best-effort wiping of RSA keygen seed copies Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-rustcrypto/src/rsa.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/picky-crypto-rustcrypto/src/rsa.rs b/picky-crypto-rustcrypto/src/rsa.rs index 1689b433..d42b42e6 100644 --- a/picky-crypto-rustcrypto/src/rsa.rs +++ b/picky-crypto-rustcrypto/src/rsa.rs @@ -226,7 +226,7 @@ fn generate_pkcs8(bits: usize) -> Result { let mut seed = Zeroizing::new([0u8; 32]); getrandom::fill(&mut *seed).map_err(|_| Error::ProviderFailure)?; // Infallible seeded RNG that wipes its buffer and state on drop (chacha20 0.10.2, src/rng.rs:94–98; - // src/lib.rs:306–310). + // src/lib.rs:306–310). Wiping the seed's stack copies is best effort. let mut rng = chacha20::ChaCha20Rng::from_seed(*seed); let key = RsaPrivateKey::new_with_exp(&mut rng, bits, BoxedUint::from(65537u64)).map_err(|_| Error::ProviderFailure)?; From cc9e494aa5aa07babd31ca30f676d398f274c78b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 14 Oct 2026 22:06:50 +0900 Subject: [PATCH 20/30] docs(picky-crypto): mark untested properties in the RustCrypto backend `UNTESTED:` comments mark the properties no test checks: capacity-limit branches, PBKDF2's allocation-failure mapping, RSA blinding, ECDSA nonce hedging, RSA key-generation seed wiping and buffer zeroization. The RSA encoded-length caps state their invariant in `INVARIANT:` doc comments. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-rustcrypto/src/aead.rs | 3 +++ picky-crypto-rustcrypto/src/cbc.rs | 3 +++ picky-crypto-rustcrypto/src/ec.rs | 6 ++++-- picky-crypto-rustcrypto/src/pbkdf2.rs | 9 ++++++++- picky-crypto-rustcrypto/src/rc4.rs | 3 +++ picky-crypto-rustcrypto/src/rsa.rs | 20 ++++++++++++++++---- picky-crypto-rustcrypto/src/util.rs | 3 +++ 7 files changed, 40 insertions(+), 7 deletions(-) diff --git a/picky-crypto-rustcrypto/src/aead.rs b/picky-crypto-rustcrypto/src/aead.rs index f4e45d1d..4ece3e91 100644 --- a/picky-crypto-rustcrypto/src/aead.rs +++ b/picky-crypto-rustcrypto/src/aead.rs @@ -8,6 +8,9 @@ use std::sync::Arc; struct Gcm(AeadAlgorithm); /// This backend's capacity limit on `ciphertext || tag`, the contract's must-support maximum (2^31 − 1 bytes). +// UNTESTED: sealing and opening return `Unsupported` beyond this limit, before any allocation or copy. +// Reaching these branches needs data too large for a test. +// When changing the seal or open path, keep the check after the fixed-length checks and before the buffer is made. const SIZE_LIMIT: usize = i32::MAX as usize; const TAG_LEN: usize = 16; diff --git a/picky-crypto-rustcrypto/src/cbc.rs b/picky-crypto-rustcrypto/src/cbc.rs index 5fd677cd..f883a82d 100644 --- a/picky-crypto-rustcrypto/src/cbc.rs +++ b/picky-crypto-rustcrypto/src/cbc.rs @@ -46,6 +46,9 @@ fn check(algorithm: CipherAlgorithm, key_valid: bool, iv: &[u8], data: &[u8], bl if iv.len() != block || data.len() % block != 0 { return Err(Error::InvalidInput); } + // UNTESTED: data beyond the capacity limit returns `Unsupported` before it is copied. + // Reaching this branch needs data too large for a test. + // When changing it, keep it after the fixed-length checks and before any copy. if data.len() > DATA_SIZE_LIMIT { return Err(Error::Unsupported(picky_crypto::Algorithm::Cipher(algorithm))); } diff --git a/picky-crypto-rustcrypto/src/ec.rs b/picky-crypto-rustcrypto/src/ec.rs index 33628e23..5204bfdf 100644 --- a/picky-crypto-rustcrypto/src/ec.rs +++ b/picky-crypto-rustcrypto/src/ec.rs @@ -102,8 +102,10 @@ macro_rules! curve { return Err(Error::Unsupported(Algorithm::Signature(algorithm))); } let signing = SigningKey::from(&self.0); - // Hedged RFC 6979 nonce: random bytes enter as additional data, and an RNG failure is - // the only error on this path (ecdsa 0.17.0, src/signing.rs:209–232, 242–265). + // UNTESTED: the RFC 6979 nonce is hedged with operating-system random bytes as additional data. + // Hedging guards deterministic nonces against fault attacks, and signatures verify either way. + // When changing the call, keep a generator argument; an RNG failure is the only error on this + // path (ecdsa 0.17.0, src/signing.rs:209–232, 242–265). let signature: Signature = signing .try_sign_with_rng(&mut getrandom::SysRng, message) .map_err(|_| Error::ProviderFailure)?; diff --git a/picky-crypto-rustcrypto/src/pbkdf2.rs b/picky-crypto-rustcrypto/src/pbkdf2.rs index f752ce82..5469f9a7 100644 --- a/picky-crypto-rustcrypto/src/pbkdf2.rs +++ b/picky-crypto-rustcrypto/src/pbkdf2.rs @@ -21,10 +21,17 @@ impl PasswordKdf for Pbkdf2 { { return Err(Error::InvalidInput); } + // UNTESTED: output lengths beyond the capacity limit return `Unsupported` before any allocation. + // Rule 4 orders capacity limits after standard ranges, and reaching this branch needs an output too large for + // a test. + // When changing it, keep it after the standard-range check and before the allocation. if output_len > OUTPUT_SIZE_LIMIT { return Err(Error::Unsupported(Algorithm::PasswordKdf(self.0))); } - // Only lengths beyond the must-support maximum may be refused as Unsupported. + // UNTESTED: an allocation failure returns `Unsupported` only beyond the must-support maximum, and + // `ProviderFailure` otherwise. + // `Unsupported` is not allowed inside the must-support set, and no test makes the allocation fail. + // When changing it, keep the comparison with the must-support maximum. let mut output = crate::util::buffer(output_len).map_err(|_| { if output_len > MUST_SUPPORT_MAX_OUTPUT_LEN { Error::Unsupported(Algorithm::PasswordKdf(self.0)) diff --git a/picky-crypto-rustcrypto/src/rc4.rs b/picky-crypto-rustcrypto/src/rc4.rs index 255c4b4b..056ea684 100644 --- a/picky-crypto-rustcrypto/src/rc4.rs +++ b/picky-crypto-rustcrypto/src/rc4.rs @@ -31,6 +31,9 @@ impl StreamCipherContext for Context { } let result = (|| { // This backend's capacity limit on data, the contract's general must-support bound (2^31 − 1 bytes). + // UNTESTED: data beyond the capacity limit returns `Unsupported` before it is copied. + // Reaching this branch needs data too large for a test. + // When changing it, keep it before any copy. if data.len() > i32::MAX as usize { return Err(Error::Unsupported(picky_crypto::Algorithm::StreamCipher( StreamCipherAlgorithm::Rc4, diff --git a/picky-crypto-rustcrypto/src/rsa.rs b/picky-crypto-rustcrypto/src/rsa.rs index d42b42e6..67cb4419 100644 --- a/picky-crypto-rustcrypto/src/rsa.rs +++ b/picky-crypto-rustcrypto/src/rsa.rs @@ -50,10 +50,12 @@ const fn der_integer_len(bits: usize) -> usize { const MODULUS_LEN: usize = der_integer_len(RsaPublicKey::MAX_SIZE); const EXPONENT_LEN: usize = der_integer_len((u64::BITS - RsaPublicKey::MAX_PUB_EXPONENT.leading_zeros()) as usize); -/// Longest PKCS#1 `RSAPublicKey` within the library's limits (rsa 0.10.0-rc.18, src/key.rs:216–222). +/// INVARIANT: the encoded length of the longest PKCS#1 `RSAPublicKey` within the library's limits +/// (rsa 0.10.0-rc.18, src/key.rs:216–222). const PUBLIC_KEY_MAX_LEN: usize = der_len(MODULUS_LEN + EXPONENT_LEN); -/// Longest PKCS#8 RSA private key within the library's limits: version, n, e, then d, p, q, dP, dQ and qInv, each +/// INVARIANT: the encoded length of the longest PKCS#8 RSA private key within the library's limits: version, n, e, +/// then d, p, q, dP, dQ and qInv, each /// bounded by the modulus, inside the version and `rsaEncryption` identifier of the PKCS#8 wrapper. fn private_key_max_len() -> usize { let rsa_private_key = der_len(der_integer_len(1) + MODULUS_LEN + EXPONENT_LEN + 6 * MODULUS_LEN); @@ -225,8 +227,12 @@ fn generate_pkcs8(bits: usize) -> Result { use rsa::rand_core::SeedableRng; let mut seed = Zeroizing::new([0u8; 32]); getrandom::fill(&mut *seed).map_err(|_| Error::ProviderFailure)?; - // Infallible seeded RNG that wipes its buffer and state on drop (chacha20 0.10.2, src/rng.rs:94–98; - // src/lib.rs:306–310). Wiping the seed's stack copies is best effort. + // Infallible seeded RNG. + // UNTESTED: the seed and the generator's buffer and state are wiped after use; wiping the seed's stack copies is + // best effort. + // Leftover seed bytes would reproduce the generated key. + // When changing it, keep the seed in `Zeroizing` and a generator that wipes on drop (chacha20 0.10.2, + // src/rng.rs:94–98; src/lib.rs:306–310). let mut rng = chacha20::ChaCha20Rng::from_seed(*seed); let key = RsaPrivateKey::new_with_exp(&mut rng, bits, BoxedUint::from(65537u64)).map_err(|_| Error::ProviderFailure)?; @@ -252,12 +258,18 @@ fn sign( let digest = Zeroizing::new(D::digest(message)); // The scheme blinds with a fallible generator, unlike `sign_with_rng`, and pads to size(), not whole limbs // (rsa 0.10.0-rc.18, src/key.rs:662; src/traits/padding.rs:35–40; src/pkcs1v15.rs:200–209). + // UNTESTED: the private-key operation is blinded with the operating-system generator. + // Blinding counters timing attacks on the private key, and signatures are the same either way. + // When changing the call, keep passing a generator. rsa::Pkcs1v15Sign::new::() .sign(Some(&mut getrandom::SysRng), key, &digest) .map(|signature| OutputBytes::new(Zeroizing::new(signature))) .map_err(|error| signing_error(error, algorithm)) } fn decrypt(key: &RsaPrivateKey, ciphertext: &[u8], padding: P) -> Result { + // UNTESTED: the private-key operation is blinded with the operating-system generator. + // Blinding counters timing attacks on the private key, and plaintexts are the same either way. + // When changing the call, keep passing a generator. padding .decrypt(Some(&mut getrandom::SysRng), key, ciphertext) .map(|output| OutputBytes::new(Zeroizing::new(output))) diff --git a/picky-crypto-rustcrypto/src/util.rs b/picky-crypto-rustcrypto/src/util.rs index c7c16e5f..c9becdc3 100644 --- a/picky-crypto-rustcrypto/src/util.rs +++ b/picky-crypto-rustcrypto/src/util.rs @@ -1,6 +1,9 @@ use picky_crypto::{Error, Zeroizing}; pub(crate) fn buffer(len: usize) -> Result>, Error> { + // UNTESTED: the buffer is wiped on drop. + // Outputs and intermediate copies can hold secrets such as plaintexts and derived keys. + // When changing it, keep `Zeroizing` around the vector. let mut output = Zeroizing::new(Vec::new()); output.try_reserve_exact(len).map_err(|_| Error::ProviderFailure)?; output.resize(len, 0); From 2295b934f9191281227f9dd5e546acc215cfbed9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 14 Oct 2026 22:24:24 +0900 Subject: [PATCH 21/30] docs(picky-crypto): mark crate-wide untested secret-handling properties Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-rustcrypto/src/lib.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/picky-crypto-rustcrypto/src/lib.rs b/picky-crypto-rustcrypto/src/lib.rs index 9c01a869..eb59261f 100644 --- a/picky-crypto-rustcrypto/src/lib.rs +++ b/picky-crypto-rustcrypto/src/lib.rs @@ -3,6 +3,15 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] +// UNTESTED: wherever the adapter holds secret material (keys, seeds, shared secrets, plaintexts, derived output), the +// copy stays in `Zeroizing` or is wiped after use. +// A copy left in freed memory can outlive the operation and leak through a later read or a memory dump. +// When adding code, wrap every adapter-owned secret buffer or copy in `Zeroizing` and enable the library's zeroize +// features. +// UNTESTED: no type holding secret material implements or derives `Debug`. +// A `Debug` output can carry the secret into logs or panic messages. +// When adding a type that holds a key or other secret, don't derive `Debug` on it or on any type containing it. + #[cfg(feature = "aead")] mod aead; #[cfg(any(feature = "aes", feature = "legacy"))] From 86e5e436e046f5dc6e51e28640202a675fd76d03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 14 Oct 2026 22:57:47 +0900 Subject: [PATCH 22/30] docs(picky-crypto): explain the ed25519-dalek feature ban Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-rustcrypto/deny.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/picky-crypto-rustcrypto/deny.toml b/picky-crypto-rustcrypto/deny.toml index 72713193..dce3b2eb 100644 --- a/picky-crypto-rustcrypto/deny.toml +++ b/picky-crypto-rustcrypto/deny.toml @@ -2,5 +2,8 @@ multiple-versions = "allow" [[bans.features]] +# Only this feature is banned, not the crate: it weakens ed25519-dalek's RFC 8032 §5.1.7 `S < L` check, and Cargo +# unifies features across the build. The adapter checks `S < L` itself; this ban keeps the workspace on the library's +# full check as well. crate = "ed25519-dalek" deny = ["legacy_compatibility"] From 2405421aadd2cddec955e8923e33d62c4cebe10c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 14 Oct 2026 23:09:40 +0900 Subject: [PATCH 23/30] style(picky-crypto): put each comment sentence on its own line Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 4 +-- picky-crypto-rustcrypto/deny.toml | 4 +-- picky-crypto-rustcrypto/src/curve25519.rs | 18 ++++++----- picky-crypto-rustcrypto/src/ec.rs | 8 ++--- picky-crypto-rustcrypto/src/pbkdf2.rs | 4 +-- picky-crypto-rustcrypto/src/rsa.rs | 31 ++++++++++--------- picky-crypto-testsuite/src/areas/cipher.rs | 3 +- .../src/areas/password_kdf.rs | 3 +- .../src/areas/stream_cipher.rs | 3 +- .../src/differential/symmetric.rs | 3 +- picky-crypto-testsuite/src/select.rs | 3 +- picky-crypto-testsuite/tests/differential.rs | 3 +- 12 files changed, 48 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32044817..3aedcdde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -266,8 +266,8 @@ jobs: - name: Check banned features run: cargo deny --manifest-path Cargo.toml --workspace --all-features --config picky-crypto-rustcrypto/deny.toml check bans - # ed25519-dalek's `legacy_compatibility` feature weakens the library's S < L check, - # so this run shows that the adapter's own check rejects the Wycheproof S + L signatures. + # ed25519-dalek's `legacy_compatibility` feature weakens the library's S < L check, so this run shows that the + # adapter's own check rejects the Wycheproof S + L signatures. - name: Check Ed25519 S range with legacy_compatibility run: cargo test --locked -p picky-crypto-conformance -p picky-crypto-rustcrypto --features picky-crypto-conformance/rustcrypto,ed25519-dalek/legacy_compatibility --test conformance -- rustcrypto::signature diff --git a/picky-crypto-rustcrypto/deny.toml b/picky-crypto-rustcrypto/deny.toml index dce3b2eb..7ff9b702 100644 --- a/picky-crypto-rustcrypto/deny.toml +++ b/picky-crypto-rustcrypto/deny.toml @@ -3,7 +3,7 @@ multiple-versions = "allow" [[bans.features]] # Only this feature is banned, not the crate: it weakens ed25519-dalek's RFC 8032 §5.1.7 `S < L` check, and Cargo -# unifies features across the build. The adapter checks `S < L` itself; this ban keeps the workspace on the library's -# full check as well. +# unifies features across the build. +# The adapter checks `S < L` itself; this ban keeps the workspace on the library's full check as well. crate = "ed25519-dalek" deny = ["legacy_compatibility"] diff --git a/picky-crypto-rustcrypto/src/curve25519.rs b/picky-crypto-rustcrypto/src/curve25519.rs index b3d80644..3dafb9b6 100644 --- a/picky-crypto-rustcrypto/src/curve25519.rs +++ b/picky-crypto-rustcrypto/src/curve25519.rs @@ -27,14 +27,15 @@ impl SignatureVerifier for Verify { fn verify(&self, public: PublicKey<'_>, message: &[u8], signature: &[u8]) -> Result<(), Error> { let public = public.0.try_into().map_err(|_| Error::InvalidKey)?; let key = VerifyingKey::from_bytes(public).map_err(|_| Error::InvalidKey)?; - // The library accepts non-canonical A encodings (ZIP-215, ed25519-dalek 3.0.0, src/verifying.rs:172–182), - // so the adapter compares the library's canonical recompression with the input. + // The library accepts non-canonical A encodings (ZIP-215, ed25519-dalek 3.0.0, src/verifying.rs:172–182), so + // the adapter compares the library's canonical recompression with the input. if key.to_edwards().compress().as_bytes() != public { return Err(Error::InvalidKey); } let signature = Signature::from_slice(signature).map_err(|_| Error::VerificationFailed)?; - // RFC 8032 §5.1.7 requires S < L. The library's check weakens when any crate in the build enables - // ed25519-dalek's `legacy_compatibility` feature, so the adapter checks it itself. + // RFC 8032 §5.1.7 requires S < L. + // The library's check weakens when any crate in the build enables ed25519-dalek's `legacy_compatibility` + // feature, so the adapter checks it itself. if curve25519_dalek::Scalar::from_canonical_bytes(*signature.s_bytes()) .is_none() .into() @@ -56,9 +57,9 @@ impl PrivateKeyLoader for EdLoader { return Err(Error::InvalidKey); }; let info = PrivateKeyInfoRef::from_der(der).map_err(|_| Error::InvalidKey)?; - // Ed25519's decoder ignores non-aligned public keys and rejects other lengths - // (ed25519 3.0.0, src/pkcs8.rs:180–185). - // as_bytes rejects unused bits (der 0.8.2, src/asn1/bit_string.rs:121–127). + // Ed25519's decoder ignores non-aligned public keys and rejects other lengths (ed25519 3.0.0, + // src/pkcs8.rs:180–185). + // `as_bytes` rejects unused bits (der 0.8.2, src/asn1/bit_string.rs:121–127). if info.public_key.is_some_and(|public| public.as_bytes().is_none()) { return Err(Error::InvalidKey); } @@ -147,7 +148,8 @@ impl KeyAgreement for XAgreement { false } fn generate_ephemeral(&self) -> Result, Error> { - // Mirrors x25519-dalek 3.0.0's EphemeralSecret::random(): 32 bytes from getrandom, clamping left to the library. + // Mirrors x25519-dalek 3.0.0's EphemeralSecret::random(): 32 bytes from getrandom, clamping left to the + // library. // Returns random-generator failures instead of panicking (src/x25519.rs:96–101). let mut scalar = Zeroizing::new([0u8; 32]); getrandom::fill(&mut *scalar).map_err(|_| Error::ProviderFailure)?; diff --git a/picky-crypto-rustcrypto/src/ec.rs b/picky-crypto-rustcrypto/src/ec.rs index 5204bfdf..65ec318c 100644 --- a/picky-crypto-rustcrypto/src/ec.rs +++ b/picky-crypto-rustcrypto/src/ec.rs @@ -73,8 +73,8 @@ macro_rules! curve { if inner.private_key.len() != $signature_len / 2 { return Err(Error::InvalidKey); } - // The library checks the curve OIDs and that an embedded public key matches - // (elliptic-curve 0.14.1, src/secret_key/pkcs8.rs:53–59; src/secret_key.rs:465–484). + // The library checks the curve OIDs and that an embedded public key matches (elliptic-curve 0.14.1, + // src/secret_key/pkcs8.rs:53–59; src/secret_key.rs:465–484). let key = SecretKey::try_from(info).map_err(|_| Error::InvalidKey)?; Ok(Box::new(Key(key))) } @@ -104,8 +104,8 @@ macro_rules! curve { let signing = SigningKey::from(&self.0); // UNTESTED: the RFC 6979 nonce is hedged with operating-system random bytes as additional data. // Hedging guards deterministic nonces against fault attacks, and signatures verify either way. - // When changing the call, keep a generator argument; an RNG failure is the only error on this - // path (ecdsa 0.17.0, src/signing.rs:209–232, 242–265). + // When changing the call, keep a generator argument; an RNG failure is the only error on this path + // (ecdsa 0.17.0, src/signing.rs:209–232, 242–265). let signature: Signature = signing .try_sign_with_rng(&mut getrandom::SysRng, message) .map_err(|_| Error::ProviderFailure)?; diff --git a/picky-crypto-rustcrypto/src/pbkdf2.rs b/picky-crypto-rustcrypto/src/pbkdf2.rs index 5469f9a7..1f1cb662 100644 --- a/picky-crypto-rustcrypto/src/pbkdf2.rs +++ b/picky-crypto-rustcrypto/src/pbkdf2.rs @@ -22,8 +22,8 @@ impl PasswordKdf for Pbkdf2 { return Err(Error::InvalidInput); } // UNTESTED: output lengths beyond the capacity limit return `Unsupported` before any allocation. - // Rule 4 orders capacity limits after standard ranges, and reaching this branch needs an output too large for - // a test. + // Rule 4 orders capacity limits after standard ranges, and reaching this branch needs an output too large for a + // test. // When changing it, keep it after the standard-range check and before the allocation. if output_len > OUTPUT_SIZE_LIMIT { return Err(Error::Unsupported(Algorithm::PasswordKdf(self.0))); diff --git a/picky-crypto-rustcrypto/src/rsa.rs b/picky-crypto-rustcrypto/src/rsa.rs index 67cb4419..cde46384 100644 --- a/picky-crypto-rustcrypto/src/rsa.rs +++ b/picky-crypto-rustcrypto/src/rsa.rs @@ -50,13 +50,13 @@ const fn der_integer_len(bits: usize) -> usize { const MODULUS_LEN: usize = der_integer_len(RsaPublicKey::MAX_SIZE); const EXPONENT_LEN: usize = der_integer_len((u64::BITS - RsaPublicKey::MAX_PUB_EXPONENT.leading_zeros()) as usize); -/// INVARIANT: the encoded length of the longest PKCS#1 `RSAPublicKey` within the library's limits -/// (rsa 0.10.0-rc.18, src/key.rs:216–222). +/// INVARIANT: the encoded length of the longest PKCS#1 `RSAPublicKey` within the library's limits (rsa 0.10.0-rc.18, +/// src/key.rs:216–222). const PUBLIC_KEY_MAX_LEN: usize = der_len(MODULUS_LEN + EXPONENT_LEN); -/// INVARIANT: the encoded length of the longest PKCS#8 RSA private key within the library's limits: version, n, e, -/// then d, p, q, dP, dQ and qInv, each -/// bounded by the modulus, inside the version and `rsaEncryption` identifier of the PKCS#8 wrapper. +/// INVARIANT: the encoded length of the longest PKCS#8 RSA private key within the library's limits: version, n, e, then +/// d, p, q, dP, dQ and qInv, each bounded by the modulus, inside the version and `rsaEncryption` identifier of the +/// PKCS#8 wrapper. fn private_key_max_len() -> usize { let rsa_private_key = der_len(der_integer_len(1) + MODULUS_LEN + EXPONENT_LEN + 6 * MODULUS_LEN); let algorithm = der_len(der_len(rsa::pkcs1::ALGORITHM_OID.as_bytes().len()) + der_len(0)); @@ -74,8 +74,8 @@ fn load_public_key(der: &[u8], algorithm: Algorithm) -> Result Error::Unsupported(algorithm), _ => Error::InvalidKey, @@ -132,8 +132,8 @@ fn encryption_supported(algorithm: AsymmetricEncryptionAlgorithm) -> bool { ) } fn error(error: rsa::Error) -> Error { - // Decryption re-encrypts its result and reports a mismatch as Internal, mapped to InvalidKey - // (rsa 0.10.0-rc.18, src/algorithms/rsa.rs:156–172; src/pkcs1v15.rs:180; src/oaep.rs:279). + // Decryption re-encrypts its result and reports a mismatch as Internal, mapped to InvalidKey (rsa 0.10.0-rc.18, + // src/algorithms/rsa.rs:156–172; src/pkcs1v15.rs:180; src/oaep.rs:279). match error { rsa::Error::Rng => Error::ProviderFailure, rsa::Error::MessageTooLong => Error::InvalidInput, @@ -192,10 +192,11 @@ impl PrivateKeyLoader for Loader { return Err(Error::InvalidKey); } let parsed = rsa::pkcs1::RsaPrivateKey::from_der(info.private_key.as_bytes()).map_err(|_| Error::InvalidKey)?; - // The library's public-key limits apply before decoding: it loads private keys of any modulus size - // (rsa 0.10.0-rc.18, src/key.rs:706–710) and reports an oversized exponent only as a malformed key - // (src/encoding.rs:66; src/key.rs:723–729). `as_bytes` strips the sign octet, so 8192 bits is 1024 bytes - // (der 0.8.2, src/asn1/integer/uint.rs:351–365,406–416). + // The library's public-key limits apply before decoding: it loads private keys of any modulus size (rsa + // 0.10.0-rc.18, src/key.rs:706–710) and reports an oversized exponent only as a malformed key + // (src/encoding.rs:66; src/key.rs:723–729). + // `as_bytes` strips the sign octet, so 8192 bits is 1024 bytes (der 0.8.2, + // src/asn1/integer/uint.rs:351–365,406–416). let exponent = parsed.public_exponent.as_bytes(); if parsed.modulus.as_bytes().len() > RsaPublicKey::MAX_SIZE / 8 || uint(exponent, precision(exponent)?)? > BoxedUint::from(RsaPublicKey::MAX_PUB_EXPONENT) @@ -256,8 +257,8 @@ fn sign( algorithm: SignatureAlgorithm, ) -> Result { let digest = Zeroizing::new(D::digest(message)); - // The scheme blinds with a fallible generator, unlike `sign_with_rng`, and pads to size(), not whole limbs - // (rsa 0.10.0-rc.18, src/key.rs:662; src/traits/padding.rs:35–40; src/pkcs1v15.rs:200–209). + // The scheme blinds with a fallible generator, unlike `sign_with_rng`, and pads to size(), not whole limbs (rsa + // 0.10.0-rc.18, src/key.rs:662; src/traits/padding.rs:35–40; src/pkcs1v15.rs:200–209). // UNTESTED: the private-key operation is blinded with the operating-system generator. // Blinding counters timing attacks on the private key, and signatures are the same either way. // When changing the call, keep passing a generator. diff --git a/picky-crypto-testsuite/src/areas/cipher.rs b/picky-crypto-testsuite/src/areas/cipher.rs index 45df6c00..94d38c04 100644 --- a/picky-crypto-testsuite/src/areas/cipher.rs +++ b/picky-crypto-testsuite/src/areas/cipher.rs @@ -131,7 +131,8 @@ pub fn run(p: &CryptoProvider, _: Options) { ); } if a == CipherAlgorithm::Rc2Cbc { - // Controls for the rejected lengths 0 and 129: the adjacent in-range lengths, outside the must-support set. + // Controls for the rejected lengths 0 and 129: the adjacent in-range lengths, outside the must-support + // set. for length in [1, 128] { let (source, in_range) = select::message_of_length(length); let id = format!("{a:?}/{protection:?}/key length={length}/{source}"); diff --git a/picky-crypto-testsuite/src/areas/password_kdf.rs b/picky-crypto-testsuite/src/areas/password_kdf.rs index 5b7fe3a5..bfa5f8f1 100644 --- a/picky-crypto-testsuite/src/areas/password_kdf.rs +++ b/picky-crypto-testsuite/src/areas/password_kdf.rs @@ -55,7 +55,8 @@ pub fn run(p: &CryptoProvider, _: Options) { || e.derive(&password, &salt, iterations, length), ); } - // Control for zero iterations and an empty output: one iteration with a one-byte output, in the must-support set. + // Control for zero iterations and an empty output: one iteration with a one-byte output, in the must-support + // set. // No control sits at the RFC 8018 maximum: deriving that much output could exhaust time or memory. let id = format!("{a:?}/bounds/1/1/{source}"); if let Some(out) = c.call(&id, Expect::Success, || e.derive(&password, &salt, 1, 1)) { diff --git a/picky-crypto-testsuite/src/areas/stream_cipher.rs b/picky-crypto-testsuite/src/areas/stream_cipher.rs index f6c17fb6..6893b42d 100644 --- a/picky-crypto-testsuite/src/areas/stream_cipher.rs +++ b/picky-crypto-testsuite/src/areas/stream_cipher.rs @@ -41,7 +41,8 @@ pub fn run(p: &CryptoProvider, _: Options) { c.check(&id, parts == whole.as_ref(), "two calls must continue one keystream"); c.bytes(&format!("{id}/applied twice"), &restored, message); } - // Control for the rejected length 0: the adjacent in-range length, outside the must-support set; 256 bytes is checked above. + // Control for the rejected length 0: the adjacent in-range length, outside the must-support set; 256 bytes + // is checked above. let (source, key) = select::message_of_length(1); let id = format!("{a:?}/key length=1/{source}"); if let Some(out) = c.outcome(&id, Expect::Success, Some((Algorithm::StreamCipher(a), false)), || { diff --git a/picky-crypto-testsuite/src/differential/symmetric.rs b/picky-crypto-testsuite/src/differential/symmetric.rs index c03c827a..e652ac44 100644 --- a/picky-crypto-testsuite/src/differential/symmetric.rs +++ b/picky-crypto-testsuite/src/differential/symmetric.rs @@ -1,4 +1,5 @@ -//! Checks one provider alone can't make: derived lengths, empty-key HMAC tags and boundary controls without published answers, and AEAD sealed by one provider and opened by the other. +//! Checks one provider alone can't make: derived lengths, empty-key HMAC tags and boundary controls without published +//! answers, and AEAD sealed by one provider and opened by the other. use picky_crypto::*; use proptest::prelude::*; diff --git a/picky-crypto-testsuite/src/select.rs b/picky-crypto-testsuite/src/select.rs index 3b095856..6c5c0956 100644 --- a/picky-crypto-testsuite/src/select.rs +++ b/picky-crypto-testsuite/src/select.rs @@ -211,7 +211,8 @@ pub fn rc4_longest_key() -> (String, Vec) { (format!("{file}/tcId={}/ct", v::number(t, "tcId")), v::field(t, "ct")) } -/// A published message of exactly `len` bytes, used as an in-range key next to a rejected length; checks using it expect only contract properties. +/// A published message of exactly `len` bytes, used as an in-range key next to a rejected length; checks using it +/// expect only contract properties. pub fn message_of_length(len: usize) -> (String, Vec) { let file = "hmac_sha1_test.json"; let vectors = v::wycheproof(file); diff --git a/picky-crypto-testsuite/tests/differential.rs b/picky-crypto-testsuite/tests/differential.rs index 369290e6..6d3bf776 100644 --- a/picky-crypto-testsuite/tests/differential.rs +++ b/picky-crypto-testsuite/tests/differential.rs @@ -124,7 +124,8 @@ impl Cipher for FixedRc2 { } } -/// PBKDF2-HMAC-SHA1 that returns `output_len` copies of `pad` above 64 bytes, beyond the published-input property's lengths, and zeros otherwise. +/// PBKDF2-HMAC-SHA1 that returns `output_len` copies of `pad` above 64 bytes, beyond the published-input property's +/// lengths, and zeros otherwise. struct FixedPbkdf2(u8); impl PasswordKdf for FixedPbkdf2 { fn algorithm(&self) -> PasswordKdfAlgorithm { From 55109629187aa0ac01b3fc470450f27bcca27d1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Wed, 14 Oct 2026 23:27:22 +0900 Subject: [PATCH 24/30] docs(picky-crypto): state why the RustCrypto backend reports no FIPS The thin-adapter and conformance invariants are already stated by the picky-crypto and conformance suite intents. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-rustcrypto/INTENT.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/picky-crypto-rustcrypto/INTENT.md b/picky-crypto-rustcrypto/INTENT.md index 9a5a5826..942e10b2 100644 --- a/picky-crypto-rustcrypto/INTENT.md +++ b/picky-crypto-rustcrypto/INTENT.md @@ -9,7 +9,4 @@ No family is enabled by default; each feature adds a family of entries. ## Invariants -- A backend is a thin adapter. - Real logic in a backend means the contract doesn't fit that library, and that's a reason to revisit the contract. -- It passes the full `picky-crypto-testsuite` conformance suite. -- `fips()` always returns `false`. +- `fips()` always returns `false`, because RustCrypto has no FIPS 140 validated module, so the FIPS policy rejects any provider containing its entries. From ad66cc47b84486f19205e5d9904c1bc1a5f7e61c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 15 Oct 2026 01:10:21 +0900 Subject: [PATCH 25/30] docs(picky-crypto): state outcome precedence and general RSA key consistency Rule 4 states which outcome wins (a bounds error always, a capacity limit over a structure error) without prescribing an evaluation order. Section 4 allows skipping validation work that grows with an exceeded parameter. Sections 7 and 12 describe RSA private keys whose components are not a consistent key for (n, e), which also covers composite factors. The padding-oracle sentence keeps only the protocol countermeasure, since the decrypt row already states the error. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto/CONTRACT.md | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/picky-crypto/CONTRACT.md b/picky-crypto/CONTRACT.md index ade1edde..8db0372a 100644 --- a/picky-crypto/CONTRACT.md +++ b/picky-crypto/CONTRACT.md @@ -62,7 +62,9 @@ These rules apply to every operation and are not repeated. - An input in the must-support set never returns [`Error::Unsupported`], except for policy input narrowing (section 8.2). - Where an operation states no explicit domain for a length, any length is valid and lengths up to 2^31 − 1 bytes are in the must-support set. - Lengths fixed by the algorithm or the operation, and ranges defined by the algorithm's standard (for example RFC 8018's maximum derived-key length, the RC2 and RC4 key ranges), bound the valid domain: an input outside them returns the specified error on every backend, never [`Error::Unsupported`]. - These bounds are checked first, before allocation or any cryptographic processing, with overflow-safe arithmetic on [`usize`] arguments; capacity limits (section 4) come next, which may return [`Error::Unsupported`], and structure last, which returns the error the first item above assigns. + These bounds are checked before allocation or any cryptographic processing, with overflow-safe arithmetic on [`usize`] arguments, and an error from them always takes precedence. + A capacity limit (section 4) may take precedence over a structure error, returning [`Error::Unsupported`]; otherwise a malformed input returns the error the first item above assigns. + This fixes which outcome wins, not the order among a backend's checks. 5. **No RNG parameter.** Operations that need randomness (key generation, ephemeral keys, ECDSA nonces, RSA encryption padding, AES-GCM seal nonces) use the backend's own secure random generator. No generator is passed in, because some libraries accept only their own. @@ -146,6 +148,7 @@ When both [`Error::InvalidKey`] and [`Error::InvalidInput`] could apply, report Library key-validation failures map to [`Error::InvalidKey`], except when the library only rejects a well-formed key for its size or parameters, which maps to [`Error::Unsupported`] (rule 4). A capacity limit is a backend's bound on a length or a numeric parameter (for example a maximum modulus size or public exponent), inside the valid domain and outside the must-support set. Capacity limits take precedence: an input beyond a backend's capacity limit may be refused with [`Error::Unsupported`] before its structure is examined, whether or not it is also malformed. +A backend may skip validation work that grows with the exceeded parameter, and a thin adapter keeps its library's check order. [`Error::ProviderFailure`] includes "PIN required", which callers therefore cannot distinguish from other device failures; this is a known limit of the closed set, to revisit if a consumer needs to prompt for a PIN. Some libraries report a single opaque failure for verification; a backend on such a library may report [`Error::VerificationFailed`] for a public key it cannot use, including an RSA modulus outside its range. Conformance tests therefore expect [`Error::InvalidKey`] for a malformed public key within the backend's capacity limits, and accept either the result expected for a key in the must-support set or [`Error::Unsupported`] for a well-formed public key outside it; for a backend on such a library they also accept [`Error::VerificationFailed`] in both cases. @@ -552,7 +555,7 @@ Cryptographically invalid inputs still fail with the specified error (for exampl Conformance tests check every [`KeyOperation`] on unwrapped keys. When [`PrivateKey::supports`] is false, the operation returns [`Error::Unsupported`]. When [`PrivateKey::supports`] is true, valid known-answer and round-trip cases succeed, no must-support input returns [`Error::Unsupported`], and invalid inputs return the specified error. -RSA keys with inconsistent or non-distinct components are checked as section 12 states. +RSA keys with components that are not a consistent RSA private key for (`n`, `e`) are checked as section 12 states. [`PrivateKey::key_size_bits`] reports: @@ -574,13 +577,13 @@ Valid domain and must-support set: RSASSA-PKCS1-v1_5 signatures always encode NULL DigestInfo parameters (section 6.9). -Decryption reports invalid padding as [`Error::VerificationFailed`]; a protocol exposed to padding oracles applies its own countermeasure (for example RFC 7516 §11.5: substitute a random key and continue). +A protocol exposed to padding oracles applies its own countermeasure (for example RFC 7516 §11.5: substitute a random key and continue). RSA private-key results are checked: every signature or plaintext is the correct result under the key's public components (`n`, `e`), or the operation fails. -A backend never outputs a signature or plaintext computed from inconsistent key components, because a single faulty CRT signature reveals a prime factor of `n`. -Handling an RSA private key with inconsistent or non-distinct components (wrong `dP`, `dQ`, `qInv` or `d`, or `p` = `q`) is implementation-defined. +A backend never outputs a signature or plaintext computed from components that are not a consistent RSA private key for (`n`, `e`), because a single faulty CRT signature reveals a prime factor of `n`. +Handling an RSA private key with components that are not a consistent RSA private key for (`n`, `e`), for example a wrong `dP`, `dQ`, `qInv` or `d`, or factors `p` and `q` that are not distinct primes, is implementation-defined. The outcome may differ per call: each operation for which [`PrivateKey::supports`] is true, including [`PrivateKey::public_key`], either returns [`Error::InvalidKey`] or behaves as specified for the key's public components (`n`, `e`), producing a signature or plaintext correct under them or the error specified for that input, such as [`Error::VerificationFailed`] when the correctly decrypted value has invalid padding. -For example, a backend may reject the key at loading with [`Error::InvalidKey`], reject it at the first private-key operation with [`Error::InvalidKey`], use CRT components the library recomputes from `n`, `e`, `d`, `p` and `q` when the primes are distinct, or check each result against (`n`, `e`). +For example, a backend may reject the key at loading with [`Error::InvalidKey`], reject it at the first private-key operation with [`Error::InvalidKey`], use CRT components the library recomputes from `n`, `e`, `d`, `p` and `q` when `p` and `q` are distinct primes, or check each result against (`n`, `e`). Each of these is allowed only when every result satisfies the rules above. This is an exception to rule 4, which would otherwise require rejection at loading. An inconsistency detected during [`PrivateKey::sign`] or [`PrivateKey::decrypt`] returns [`Error::InvalidKey`], never [`Error::VerificationFailed`], which here reports only invalid padding in a value decrypted correctly under (`n`, `e`) or a ciphertext not less than `n`. @@ -825,7 +828,7 @@ For each of them, conformance tests assert that nothing panics and that the resu - RSA PKCS#1 v1.5 verification of a DigestInfo with absent parameters (section 6.9): success or [`Error::VerificationFailed`], the result section 6.9 gives for a signature that does not verify; - loading a PKCS#8 document that carries `attributes` (section 5.2): success or [`Error::InvalidKey`]; - a 3DES key whose component keys are not pairwise distinct or are DES weak or semi-weak keys (section 6.5): success or [`Error::InvalidKey`]; -- handling of an RSA private key with inconsistent or non-distinct components (section 7): loading returns [`Error::InvalidKey`] or succeeds. +- handling of an RSA private key with components that are not a consistent RSA private key for (`n`, `e`) (section 7): loading returns [`Error::InvalidKey`] or succeeds. A loaded key returns [`Error::Unsupported`] for every operation for which [`PrivateKey::supports`] is false; for an operation for which it is true, only the outcomes below are allowed for each test input. Exporting the public key returns [`Error::InvalidKey`] or exactly the section 5.1 encoding of the published (`n`, `e`) of the base key (below). Signing returns [`Error::InvalidKey`] or a signature that verifies under (`n`, `e`). @@ -837,14 +840,14 @@ For each of them, conformance tests assert that nothing panics and that the resu The suite uses only published ciphertexts labeled valid, so every ciphertext from any of the three sources has valid padding, and [`Error::VerificationFailed`] is a conformance failure for it, because a decryption correct under (`n`, `e`) recovers the valid padding. Each backend is checked on its own; differential runs do not repeat this check or compare outcomes between backends. -The keys for that last property are derived from published keys, because the known published RSA test vectors contain no keys with inconsistent components: +The keys for that last property are derived from published keys, because the known published RSA test vectors contain no such keys: - base keys come from cited published sources and are in the must-support set of section 7; - a key is derived only by swapping or substituting whole encoded INTEGER fields between cited keys: `dP` with `dQ`, `p` with `q` leaving `qInv` unchanged, or `qInv` taken from another cited key of the same size; no arithmetic, no generated bytes and no randomness; - round-trip control: splitting a base key into its fields and reassembling them unchanged must reproduce the cited key's exact bytes, otherwise the test fails; - positive control: the unmodified base key loads and, for each operation for which [`PrivateKey::supports`] is true, its exported public key is exactly the section 5.1 encoding of (`n`, `e`), its signatures verify and, with a ciphertext from the source chosen as for its derived keys, its decryptions return the expected plaintext on the backend under test. -No known published key has non-distinct components (`p` = `q`, so `n` = `p`²), and these derivations cannot produce one, so that case has no conformance vector. +No known published key has equal factors (`p` = `q`, so `n` = `p`²), and these derivations produce neither equal nor composite factors, so those cases have no conformance vector. `picky-crypto`'s tests check composition (section 8.1) for [`Mac`], [`Cipher`], [`Aead`] and [`KeyWrap`] against mock providers, over every combination of supported protections and FIPS reports: a fallback fills in a protection the primary does not support, the primary serves a protection both members support, and the composite entry's FIPS report and the composed provider's [`CryptoProvider::fips`] follow section 8.1. For [`Mac`], each context starts on the member serving the requested protection. From 4a0b53be62d52debe4a19bfe46439f7a41c959dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 15 Oct 2026 01:10:21 +0900 Subject: [PATCH 26/30] docs(picky-crypto): describe RSA encoding caps as defense in depth DER decoding at the locked versions does not grow the stack with input size, and the loader's modulus check keeps oversized keys away from the library's key construction. The README separates error masking from the timing advisory, and AGENTS.md also forbids editing the contract to make the backend pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-rustcrypto/AGENTS.md | 3 +-- picky-crypto-rustcrypto/README.md | 5 ++++- picky-crypto-rustcrypto/src/pbkdf2.rs | 4 ++-- picky-crypto-rustcrypto/src/rsa.rs | 6 +++--- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/picky-crypto-rustcrypto/AGENTS.md b/picky-crypto-rustcrypto/AGENTS.md index c52262bc..037595b9 100644 --- a/picky-crypto-rustcrypto/AGENTS.md +++ b/picky-crypto-rustcrypto/AGENTS.md @@ -2,6 +2,5 @@ - Inline tests cover adapter logic only: argument mapping, length and constant-byte checks. Do not retest behavior covered by the conformance suite or the library. -- Never modify `picky-crypto-testsuite` to make this backend pass. - Escalate a failing case with its cited source, vector identifier, expected outcome and native library behavior. +- Never edit `picky-crypto/CONTRACT.md` or `picky-crypto-testsuite` to make this backend pass; escalate with evidence: the failing case's cited source, vector identifier, expected outcome and native library behavior. - Cite the source file or documentation page at the version locked in `Cargo.lock` when relying on a library capability, or mark it Unknown. diff --git a/picky-crypto-rustcrypto/README.md b/picky-crypto-rustcrypto/README.md index d0b67c79..cd8da7a1 100644 --- a/picky-crypto-rustcrypto/README.md +++ b/picky-crypto-rustcrypto/README.md @@ -40,7 +40,10 @@ RSA key generation draws a 256-bit seed from the operating-system generator and RSA signing and decryption blind the private-key operation with the operating-system generator. PKCS#1 v1.5 decryption is subject to RUSTSEC-2023-0071 (the Marvin timing attack), for which the rsa crate has no fixed release. In the locked rsa version the padding checks use constant-time comparisons, except the minimum padding-length check, which the crate flags as possibly not constant time (`src/algorithms/pkcs1v15.rs`, `decrypt_inner`). -Invalid padding is reported as `VerificationFailed`, as the contract requires; a protocol exposed to padding oracles applies its own countermeasure. + +Every invalid padding is reported as the same `VerificationFailed` error, as the contract requires. +This removes distinguishable errors, not timing differences inside the RSA operation; a protocol exposed to padding oracles applies its own countermeasure. + ECDSA signing mixes operating-system randomness into the RFC 6979 nonce as additional data. RSA public keys beyond the rsa crate's limits (a modulus above 8192 bits or a public exponent above 2^33 − 1) are unsupported. Private-key loading applies the same limits, although the rsa crate loads private keys of any modulus size. diff --git a/picky-crypto-rustcrypto/src/pbkdf2.rs b/picky-crypto-rustcrypto/src/pbkdf2.rs index 1f1cb662..bef6bc4c 100644 --- a/picky-crypto-rustcrypto/src/pbkdf2.rs +++ b/picky-crypto-rustcrypto/src/pbkdf2.rs @@ -22,8 +22,8 @@ impl PasswordKdf for Pbkdf2 { return Err(Error::InvalidInput); } // UNTESTED: output lengths beyond the capacity limit return `Unsupported` before any allocation. - // Rule 4 orders capacity limits after standard ranges, and reaching this branch needs an output too large for a - // test. + // Rule 4 gives standard-range errors precedence over capacity limits, and reaching this branch needs an output + // too large for a test. // When changing it, keep it after the standard-range check and before the allocation. if output_len > OUTPUT_SIZE_LIMIT { return Err(Error::Unsupported(Algorithm::PasswordKdf(self.0))); diff --git a/picky-crypto-rustcrypto/src/rsa.rs b/picky-crypto-rustcrypto/src/rsa.rs index cde46384..1da8fbe0 100644 --- a/picky-crypto-rustcrypto/src/rsa.rs +++ b/picky-crypto-rustcrypto/src/rsa.rs @@ -64,7 +64,7 @@ fn private_key_max_len() -> usize { } fn load_public_key(der: &[u8], algorithm: Algorithm) -> Result { - // Capacity limits take precedence over structure, so no oversized input reaches the library's decoders. + // Defense in depth: capacity limits take precedence over structure, so the cap bounds work before decoding. if der.len() > PUBLIC_KEY_MAX_LEN { return Err(Error::Unsupported(algorithm)); } @@ -176,8 +176,8 @@ impl PrivateKeyLoader for Loader { let PrivateKeyMaterial::Pkcs8(der) = material else { return Err(Error::InvalidKey); }; - // Capacity limits take precedence over structure, so no oversized input reaches the library's decoders, whose - // stack use grows with the input. + // Defense in depth: capacity limits take precedence over structure, so the cap bounds work before decoding. + // The modulus check below already keeps oversized keys away from the library's key construction. if der.len() > private_key_max_len() { return Err(Error::Unsupported(Algorithm::PrivateKeyLoading(KeyType::Rsa))); } From 488a0270f211da4b082e7c81055a7c0cebaa5d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 15 Oct 2026 01:10:21 +0900 Subject: [PATCH 27/30] test(picky-crypto): check base-key export and both PBKDF2 control lengths The inconsistent-RSA areas check the base key's public-key export next to its positive-control load. The differential self-test's PBKDF2 mock differs at both compared control lengths, and each length is asserted separately. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/areas/private_key.rs | 2 + picky-crypto-testsuite/tests/asymmetric.rs | 5 +- picky-crypto-testsuite/tests/differential.rs | 47 ++++++++++++------- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/picky-crypto-testsuite/src/areas/private_key.rs b/picky-crypto-testsuite/src/areas/private_key.rs index 5350895e..9b4bea67 100644 --- a/picky-crypto-testsuite/src/areas/private_key.rs +++ b/picky-crypto-testsuite/src/areas/private_key.rs @@ -404,6 +404,7 @@ fn inconsistent_rsa_signing(c: &mut Checks, p: &CryptoProvider, options: Options }) else { continue; }; + exported(c, &id, &*base, &public); let other = bases .iter() .find(|other| v::field(other, "privateKeyPkcs8") != encoding) @@ -469,6 +470,7 @@ fn inconsistent_rsa_decryption(c: &mut Checks, p: &CryptoProvider, options: Opti continue; }; let public = der::rsa_public(&encoded); + exported(c, &id, &*base, &public); let ciphertext = v::field(t, "ct"); let plaintext = v::field(t, "msg"); let published = Some((a, ciphertext.as_slice(), plaintext.as_slice())); diff --git a/picky-crypto-testsuite/tests/asymmetric.rs b/picky-crypto-testsuite/tests/asymmetric.rs index b8037b3c..db03d3b1 100644 --- a/picky-crypto-testsuite/tests/asymmetric.rs +++ b/picky-crypto-testsuite/tests/asymmetric.rs @@ -764,9 +764,10 @@ impl PrivateKeyLoader for TableLoader { } #[test] -fn derived_keys_without_signing_get_export_and_decryption_checks() { +fn base_and_derived_keys_without_signing_get_export_and_decryption_checks() { let table = Table::default(); // Every export and decryption returns an unaccepted error, so each check leaves a failure naming its file. + // The base key's export check is the one directly under "inconsistent components"; derived keys add a variant. let key = TableKey { outcome: Some(Err(Error::VerificationFailed)), exports: true, @@ -788,7 +789,7 @@ fn derived_keys_without_signing_get_export_and_decryption_checks() { .into_iter() .chain(RSA_DECRYPT_FILES.map(|(file, _)| file)) { - for check in ["/public_key: ", " ciphertext: "] { + for check in ["inconsistent components/public_key: ", "/public_key: ", " ciphertext: "] { assert!( c.failures.iter().any(|f| f.starts_with(file) && f.contains(check)), "{file}{check}{:?}", diff --git a/picky-crypto-testsuite/tests/differential.rs b/picky-crypto-testsuite/tests/differential.rs index 6d3bf776..9edfbb72 100644 --- a/picky-crypto-testsuite/tests/differential.rs +++ b/picky-crypto-testsuite/tests/differential.rs @@ -2,6 +2,7 @@ use std::sync::{Arc, Mutex}; use picky_crypto::*; use picky_crypto_testsuite::differential; +use picky_crypto_testsuite::harness::without_failure_persistence; use picky_crypto_testsuite::keys::RSA_SIGN_FILES; use picky_crypto_testsuite::vectors as v; @@ -73,10 +74,13 @@ fn empty_key_hmac_tags_must_agree(#[case] tag: u8, #[case] generates: bool, #[ca .unwrap() }; let (a, b) = (provider(1, true), provider(tag, generates)); - let failure = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| differential::symmetric::run(&a, &b))) - .err() - .and_then(|panic| panic.downcast_ref::().cloned()) - .unwrap_or_default(); + // The published-input property fails on purpose when the 1-byte output differs. + let failure = without_failure_persistence(|| { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| differential::symmetric::run(&a, &b))) + }) + .err() + .and_then(|panic| panic.downcast_ref::().cloned()) + .unwrap_or_default(); assert_eq!(failure.contains("empty-key tags differ"), fails, "{failure}"); } @@ -124,8 +128,9 @@ impl Cipher for FixedRc2 { } } -/// PBKDF2-HMAC-SHA1 that returns `output_len` copies of `pad` above 64 bytes, beyond the published-input property's -/// lengths, and zeros otherwise. +/// PBKDF2-HMAC-SHA1 that returns `output_len` copies of `pad` for a 1-byte output and above 64 bytes, and zeros +/// otherwise. +/// Both compared control lengths differ; the published-input property also fails at a 1-byte output, under its own id. struct FixedPbkdf2(u8); impl PasswordKdf for FixedPbkdf2 { fn algorithm(&self) -> PasswordKdfAlgorithm { @@ -135,18 +140,21 @@ impl PasswordKdf for FixedPbkdf2 { false } fn derive(&self, _: &[u8], _: &[u8], _: u32, output_len: usize) -> Result { - let byte = if output_len > 64 { self.0 } else { 0 }; + let byte = if output_len == 1 || output_len > 64 { self.0 } else { 0 }; Ok(OutputBytes::new(Zeroizing::new(vec![byte; output_len]))) } } +const PBKDF2_SHORT: &str = "Pbkdf2HmacSha1/bounds/1/1/"; +const PBKDF2_LONG: &str = "Pbkdf2HmacSha1/bounds/1/1025/"; + /// Each boundary-control comparison fails exactly when the two providers' entries for it disagree. #[rstest::rstest] -#[case::all_agree([1, 1, 1], None)] -#[case::rc4_differs([2, 1, 1], Some("Rc4/key length="))] -#[case::rc2_differs([1, 2, 1], Some("Rc2Cbc/"))] -#[case::pbkdf2_differs([1, 1, 2], Some("Pbkdf2HmacSha1/bounds/1/"))] -fn boundary_control_outputs_must_agree(#[case] pads: [u8; 3], #[case] differing: Option<&str>) { +#[case::all_agree([1, 1, 1], &[])] +#[case::rc4_differs([2, 1, 1], &["Rc4/key length="])] +#[case::rc2_differs([1, 2, 1], &["Rc2Cbc/"])] +#[case::pbkdf2_differs([1, 1, 2], &[PBKDF2_SHORT, PBKDF2_LONG])] +fn boundary_control_outputs_must_agree(#[case] pads: [u8; 3], #[case] differing: &[&str]) { let provider = |[rc4, rc2, pbkdf2]: [u8; 3]| { CryptoProvider::builder() .with(Entry::StreamCipher(Arc::new(FixedRc4(rc4)))) @@ -156,16 +164,19 @@ fn boundary_control_outputs_must_agree(#[case] pads: [u8; 3], #[case] differing: .unwrap() }; let (a, b) = (provider([1, 1, 1]), provider(pads)); - let failure = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| differential::symmetric::run(&a, &b))) - .err() - .and_then(|panic| panic.downcast_ref::().cloned()) - .unwrap_or_default(); + // The published-input property fails on purpose when the 1-byte output differs. + let failure = without_failure_persistence(|| { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| differential::symmetric::run(&a, &b))) + }) + .err() + .and_then(|panic| panic.downcast_ref::().cloned()) + .unwrap_or_default(); let differ = |marker: &str| { failure .lines() .any(|line| line.contains(marker) && line.contains("/differential") && line.contains("outputs differ")) }; - for marker in ["Rc4/key length=", "Rc2Cbc/", "Pbkdf2HmacSha1/bounds/1/"] { - assert_eq!(differ(marker), differing == Some(marker), "{marker}: {failure}"); + for marker in ["Rc4/key length=", "Rc2Cbc/", PBKDF2_SHORT, PBKDF2_LONG] { + assert_eq!(differ(marker), differing.contains(&marker), "{marker}: {failure}"); } } From 52ea4bf3eaf5c144494588a4a98c051b2dacd075 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 15 Oct 2026 13:23:44 +0900 Subject: [PATCH 28/30] fix(picky-crypto): drop the RSA private-key encoding cap The PKCS#8 decoder skips attributes of any length, so a whole-document cap refused valid maximum-size keys with attributes, which the contract allows only to load or to fail with InvalidKey. DER parsing does not grow with the input, and the modulus and exponent check after parsing already keeps oversized keys away from the library's key construction. The exact public-key cap stays. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-rustcrypto/README.md | 3 +- picky-crypto-rustcrypto/src/rsa.rs | 54 +++++------------------------- 2 files changed, 11 insertions(+), 46 deletions(-) diff --git a/picky-crypto-rustcrypto/README.md b/picky-crypto-rustcrypto/README.md index cd8da7a1..4e70c014 100644 --- a/picky-crypto-rustcrypto/README.md +++ b/picky-crypto-rustcrypto/README.md @@ -47,7 +47,8 @@ This removes distinguishable errors, not timing differences inside the RSA opera ECDSA signing mixes operating-system randomness into the RFC 6979 nonce as additional data. RSA public keys beyond the rsa crate's limits (a modulus above 8192 bits or a public exponent above 2^33 − 1) are unsupported. Private-key loading applies the same limits, although the rsa crate loads private keys of any modulus size. -Encoded RSA keys longer than the largest encoding within those limits are unsupported before they are decoded, whether or not they are also malformed. +Encoded RSA public keys longer than the largest encoding within those limits are unsupported before they are decoded, whether or not they are also malformed. +Private keys get no such cap, because the decoder skips PKCS#8 attributes of any length; their modulus and exponent are checked after parsing, before key construction. ## Conformance tests diff --git a/picky-crypto-rustcrypto/src/rsa.rs b/picky-crypto-rustcrypto/src/rsa.rs index 1da8fbe0..32c1121b 100644 --- a/picky-crypto-rustcrypto/src/rsa.rs +++ b/picky-crypto-rustcrypto/src/rsa.rs @@ -54,15 +54,6 @@ const EXPONENT_LEN: usize = der_integer_len((u64::BITS - RsaPublicKey::MAX_PUB_E /// src/key.rs:216–222). const PUBLIC_KEY_MAX_LEN: usize = der_len(MODULUS_LEN + EXPONENT_LEN); -/// INVARIANT: the encoded length of the longest PKCS#8 RSA private key within the library's limits: version, n, e, then -/// d, p, q, dP, dQ and qInv, each bounded by the modulus, inside the version and `rsaEncryption` identifier of the -/// PKCS#8 wrapper. -fn private_key_max_len() -> usize { - let rsa_private_key = der_len(der_integer_len(1) + MODULUS_LEN + EXPONENT_LEN + 6 * MODULUS_LEN); - let algorithm = der_len(der_len(rsa::pkcs1::ALGORITHM_OID.as_bytes().len()) + der_len(0)); - der_len(der_integer_len(1) + algorithm + der_len(rsa_private_key)) -} - fn load_public_key(der: &[u8], algorithm: Algorithm) -> Result { // Defense in depth: capacity limits take precedence over structure, so the cap bounds work before decoding. if der.len() > PUBLIC_KEY_MAX_LEN { @@ -176,11 +167,6 @@ impl PrivateKeyLoader for Loader { let PrivateKeyMaterial::Pkcs8(der) = material else { return Err(Error::InvalidKey); }; - // Defense in depth: capacity limits take precedence over structure, so the cap bounds work before decoding. - // The modulus check below already keeps oversized keys away from the library's key construction. - if der.len() > private_key_max_len() { - return Err(Error::Unsupported(Algorithm::PrivateKeyLoading(KeyType::Rsa))); - } let info = PrivateKeyInfoRef::from_der(der).map_err(|_| Error::InvalidKey)?; if info.version() != Version::V1 { return Err(Error::InvalidKey); @@ -192,9 +178,13 @@ impl PrivateKeyLoader for Loader { return Err(Error::InvalidKey); } let parsed = rsa::pkcs1::RsaPrivateKey::from_der(info.private_key.as_bytes()).map_err(|_| Error::InvalidKey)?; - // The library's public-key limits apply before decoding: it loads private keys of any modulus size (rsa - // 0.10.0-rc.18, src/key.rs:706–710) and reports an oversized exponent only as a malformed key - // (src/encoding.rs:66; src/key.rs:723–729). + // The library's public-key limits apply after parsing and before key construction. + // The library loads private keys of any modulus size (rsa 0.10.0-rc.18, src/key.rs:706–710) and reports an + // oversized exponent only as a malformed key (src/encoding.rs:66; src/key.rs:723–729). + // Key construction converts d, p and q at the modulus precision, and its stack use grows with the modulus + // (src/encoding.rs:41–66). + // DER parsing does not grow with the input, so no length cap precedes it; PKCS#8 attributes, which the decoder + // skips (pkcs8 0.11.0, src/private_key_info.rs:223–224), would make a whole-document cap refuse valid keys. // `as_bytes` strips the sign octet, so 8192 bits is 1024 bytes (der 0.8.2, // src/asn1/integer/uint.rs:351–365,406–416). let exponent = parsed.public_exponent.as_bytes(); @@ -436,11 +426,10 @@ mod tests { } } - // The caps are the library's encodings of the largest keys within its limits; longer inputs are refused undecoded. + // The cap is the library's encoding of the largest public key within its limits; longer inputs are refused undecoded. #[test] - fn encoded_length_caps() { + fn encoded_length_cap() { let algorithm = SignatureAlgorithm::RsaPkcs1v15Sha256; - let loading = Algorithm::PrivateKeyLoading(KeyType::Rsa); let n = UintRef::new(&[0xff; RsaPublicKey::MAX_SIZE / 8]).unwrap(); let exponent = RsaPublicKey::MAX_PUB_EXPONENT.to_be_bytes(); let e = UintRef::new(&exponent).unwrap(); @@ -449,22 +438,6 @@ mod tests { public_exponent: e, }; assert_eq!(public.to_der().unwrap().len(), PUBLIC_KEY_MAX_LEN); - let private = rsa::pkcs1::RsaPrivateKey { - modulus: n, - public_exponent: e, - private_exponent: n, - prime1: n, - prime2: n, - exponent1: n, - exponent2: n, - coefficient: n, - other_prime_infos: None, - } - .to_der() - .unwrap(); - let info = PrivateKeyInfoRef::new(rsa::pkcs1::ALGORITHM_ID, OctetStringRef::new(&private).unwrap()); - // Catches a private cap that miscounts the modulus-bounded fields. - assert_eq!(info.to_der().unwrap().len(), private_key_max_len()); for (len, expected) in [ (PUBLIC_KEY_MAX_LEN, Error::InvalidKey), ( @@ -477,15 +450,6 @@ mod tests { Err(expected) ); } - for (len, expected) in [ - (private_key_max_len(), Error::InvalidKey), - (private_key_max_len() + 1, Error::Unsupported(loading)), - ] { - assert_eq!( - Loader.load(PrivateKeyMaterial::Pkcs8(&vec![0; len])).err(), - Some(expected) - ); - } } // Signatures must be padded to the modulus width, not to whole limbs (Signature::to_bytes()). From de288333c957c5fbad2c6b45b85521d328435c51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 15 Oct 2026 13:38:51 +0900 Subject: [PATCH 29/30] test(picky-crypto): check signing with keys from RSA decryption vectors Base keys published only with decryption vectors, and keys derived from them, now get signing checks on the vectors' published messages. With no published signature to pin the base key, a signature passes only if the provider's verifier, or else the conformance runner's reference verifier, accepts it, and the check fails if neither exists. Keys from signature vectors keep their checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/areas/private_key.rs | 80 ++++++++-- picky-crypto-testsuite/src/harness.rs | 1 + picky-crypto-testsuite/tests/asymmetric.rs | 151 +++++++++++++++++- picky-crypto/CONTRACT.md | 1 + 4 files changed, 213 insertions(+), 20 deletions(-) diff --git a/picky-crypto-testsuite/src/areas/private_key.rs b/picky-crypto-testsuite/src/areas/private_key.rs index 9b4bea67..2fe71a14 100644 --- a/picky-crypto-testsuite/src/areas/private_key.rs +++ b/picky-crypto-testsuite/src/areas/private_key.rs @@ -192,29 +192,59 @@ pub fn inconsistent_rsa(c: &mut Checks, p: &CryptoProvider, options: Options) { inconsistent_rsa_decryption(c, p, options); } +/// The published inputs of the signing checks on an RSA key with inconsistent components. +#[derive(Clone, Copy)] +pub enum SigningInputs<'a> { + /// A signature file's group, whose published signatures pin the base key's signatures for the group's hash. + Signatures { file: &'a str, group: &'a Value }, + /// A published decryption test of the base key, whose message is signed; no published signature pins the base key. + Messages { + file: &'a str, + test: &'a Value, + public: &'a [u8], + }, +} + +/// Checks signing with an RSA key derived with inconsistent components, and with its base key, for every RSA signature algorithm. +/// +/// A signature passes only if the provider's verifier accepts it under the base key's public key. +/// For signature-file inputs without a verifier, the derived key's signature must equal the base key's instead. +/// For message inputs, the reference provider's verifier is the fallback, and the check fails without either. pub fn inconsistent_signatures( c: &mut Checks, p: &CryptoProvider, - base: &dyn PrivateKey, - key: &dyn PrivateKey, + options: Options, + keys: (&dyn PrivateKey, &dyn PrivateKey), id: &str, - source: (&str, &Value), + inputs: SigningInputs<'_>, ) { - let (file, group) = source; - let public = der::rsa_public(&v::field(group, "privateKeyPkcs8")); - let source_algorithm = rsa_signature(v::string(group, "sha")); + let (base, key) = keys; + let (file, public, source_algorithm, all_tests) = match inputs { + SigningInputs::Signatures { file, group } => ( + file, + der::rsa_public(&v::field(group, "privateKeyPkcs8")), + Some(rsa_signature(v::string(group, "sha"))), + v::tests(group), + ), + SigningInputs::Messages { file, test, public } => (file, public.to_vec(), None, std::slice::from_ref(test)), + }; + let pinned = matches!(inputs, SigningInputs::Signatures { .. }); for algorithm in SIGNATURES[..8].iter().copied() { let base_supported = c.key_supports(id, base, KeyOperation::Sign(algorithm)); let derived_supported = c.key_supports(id, key, KeyOperation::Sign(algorithm)); if !base_supported && !derived_supported { continue; } - let verifier = helpers::signature_verifier(p, algorithm).ok(); - let tests = v::tests(group); - let tests = if algorithm == source_algorithm { - tests + let verifier = helpers::signature_verifier(p, algorithm).ok().or_else(|| { + options + .reference_provider + .filter(|_| !pinned) + .and_then(|reference| helpers::signature_verifier(reference, algorithm).ok()) + }); + let tests = if Some(algorithm) == source_algorithm { + all_tests } else { - &tests[..1] + &all_tests[..1] }; for t in tests { let id = format!("{id}/{}", v::id(algorithm, file, t)); @@ -227,13 +257,15 @@ pub fn inconsistent_signatures( None }; if let Some(signature) = &positive { - if algorithm == source_algorithm { + if Some(algorithm) == source_algorithm { c.bytes(&id, signature, &v::field(t, "sig")); } if let Some(verifier) = verifier { c.call(&format!("{id}/positive verification"), Expect::Success, || { verifier.verify(PublicKey(&public), &message, signature) }); + } else if !pinned { + c.check(&format!("{id}/positive verification"), false, NO_VERIFIER); } } let expected = if derived_supported { @@ -242,27 +274,30 @@ pub fn inconsistent_signatures( Expect::Error(Error::Unsupported(Algorithm::Signature(algorithm))) }; if let Some(signature) = c.call(&id, expected, || key.sign(algorithm, &message)) { - if algorithm == source_algorithm { + if Some(algorithm) == source_algorithm { c.bytes(&id, &signature, &v::field(t, "sig")); } if let Some(verifier) = verifier { c.call(&id, Expect::Success, || { verifier.verify(PublicKey(&public), &message, &signature) }); - } else if let Some(positive) = &positive { + } else if let Some(positive) = positive.as_ref().filter(|_| pinned) { c.bytes(&id, &signature, positive); - } else { + } else if pinned { c.check( &id, false, "no verifier or deterministic positive control for inconsistent-key signature", ); + } else { + c.check(&id, false, NO_VERIFIER); } } } } } +const NO_VERIFIER: &str = "no verifier in the provider or the reference provider for an inconsistent-key signature"; /// Where the ciphertext of a derived key's decryption check comes from, in order of preference. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CiphertextSource { @@ -417,7 +452,14 @@ fn inconsistent_rsa_signing(c: &mut Checks, p: &CryptoProvider, options: Options if let Some(key) = c.call(&id, Expect::Either(Error::InvalidKey), || { loader.load(PrivateKeyMaterial::Pkcs8(&malformed)) }) { - inconsistent_signatures(c, p, &*base, &*key, &id, (file, g)); + inconsistent_signatures( + c, + p, + options, + (&*base, &*key), + &id, + SigningInputs::Signatures { file, group: g }, + ); if first { derived_export(c, &id, &*key, &public); inconsistent_decryption(c, p, options, (&*base, &*key), &id, &public, None); @@ -483,6 +525,12 @@ fn inconsistent_rsa_decryption(c: &mut Checks, p: &CryptoProvider, options: Opti loader.load(PrivateKeyMaterial::Pkcs8(&derived)) }) { derived_export(c, &id, &*key, &public); + let inputs = SigningInputs::Messages { + file, + test: t, + public: &public, + }; + inconsistent_signatures(c, p, options, (&*base, &*key), &id, inputs); inconsistent_decryption(c, p, options, (&*base, &*key), &id, &public, published); } } diff --git a/picky-crypto-testsuite/src/harness.rs b/picky-crypto-testsuite/src/harness.rs index b238b3c3..14d1c220 100644 --- a/picky-crypto-testsuite/src/harness.rs +++ b/picky-crypto-testsuite/src/harness.rs @@ -16,6 +16,7 @@ pub struct Options { pub opaque_public_key_errors: bool, /// The other backends in the same build, combined into one provider, whose RSA encryptors produce reference ciphertexts. /// Decryption checks on RSA keys with inconsistent components use it when the base key has no published ciphertext for the algorithm. + /// Signing checks on such keys from decryption files use its verifiers when the provider has none for the algorithm. pub reference_provider: Option<&'static CryptoProvider>, } diff --git a/picky-crypto-testsuite/tests/asymmetric.rs b/picky-crypto-testsuite/tests/asymmetric.rs index db03d3b1..0ce9c672 100644 --- a/picky-crypto-testsuite/tests/asymmetric.rs +++ b/picky-crypto-testsuite/tests/asymmetric.rs @@ -4,7 +4,7 @@ use picky_crypto_testsuite::areas::ffdh::{ FFC_FILE, below_modulus_minus_one, ffdh_exponent_boundaries, ffdh_parameter_boundaries, }; use picky_crypto_testsuite::areas::private_key::{ - CiphertextSource, derived_export, inconsistent_decryption, inconsistent_rsa, inconsistent_signatures, + CiphertextSource, SigningInputs, derived_export, inconsistent_decryption, inconsistent_rsa, inconsistent_signatures, }; use picky_crypto_testsuite::areas::{key_generation, signature, signature::ed25519_undecodable_keys}; use picky_crypto_testsuite::harness::{Checks, Expect, Options, without_failure_persistence}; @@ -983,6 +983,9 @@ fn inconsistent_signatures_cover_algorithms_without_generation_vectors() { let derived = SigningKey { signature: signature.clone(), }; + // Signature-file keys never fall back to the reference provider's verifiers. + let mut options = Options::default(); + options.reference_provider = Some(Box::leak(Box::new(signing_provider(&signature, true, &calls)))); for verifiers in [true, false] { calls.lock().unwrap().clear(); let provider = signing_provider(&signature, verifiers, &calls); @@ -990,10 +993,13 @@ fn inconsistent_signatures_cover_algorithms_without_generation_vectors() { inconsistent_signatures( &mut c, &provider, - &base, - &derived, + options, + (&base, &derived), "inconsistent RSA routing", - (RSA_SIGN_FILES[0], group), + SigningInputs::Signatures { + file: RSA_SIGN_FILES[0], + group, + }, ); c.finish(); let calls = calls.lock().unwrap(); @@ -1003,3 +1009,140 @@ fn inconsistent_signatures_cover_algorithms_without_generation_vectors() { } } } + +/// Loads every RSA key as an [`EchoKey`] for its public key. +struct EchoLoader { + wrong: bool, +} +/// Signs every RSA algorithm with `public || message`, with one byte flipped when `wrong` is set. +struct EchoKey { + public: Vec, + wrong: bool, +} +/// Accepts exactly `public || message`, counting its calls. +struct EchoVerifier { + algorithm: SignatureAlgorithm, + calls: Arc, +} +impl PrivateKeyLoader for EchoLoader { + fn key_type(&self) -> KeyType { + KeyType::Rsa + } + fn fips(&self) -> bool { + false + } + fn load(&self, material: PrivateKeyMaterial<'_>) -> Result, Error> { + let PrivateKeyMaterial::Pkcs8(encoded) = material else { + return Err(Error::InvalidKey); + }; + Ok(Box::new(EchoKey { + public: der::rsa_public(encoded), + wrong: self.wrong, + })) + } +} +impl PrivateKey for EchoKey { + fn key_type(&self) -> KeyType { + KeyType::Rsa + } + fn key_size_bits(&self) -> usize { + 2048 + } + fn fips(&self) -> bool { + false + } + fn supports(&self, operation: KeyOperation) -> bool { + matches!(operation, KeyOperation::Sign(_)) + } + fn sign(&self, _: SignatureAlgorithm, message: &[u8]) -> Result { + let mut signature = [self.public.as_slice(), message].concat(); + signature[0] ^= u8::from(self.wrong); + Ok(OutputBytes::new(Zeroizing::new(signature))) + } +} +impl SignatureVerifier for EchoVerifier { + fn algorithm(&self) -> SignatureAlgorithm { + self.algorithm + } + fn fips(&self) -> bool { + false + } + fn verify(&self, public_key: PublicKey<'_>, message: &[u8], signature: &[u8]) -> Result<(), Error> { + self.calls.fetch_add(1, Ordering::Relaxed); + if signature == [public_key.0, message].concat() { + Ok(()) + } else { + Err(Error::VerificationFailed) + } + } +} + +/// Keys from RSA decryption files sign published messages, verified under the base key's public key by the provider's verifier, else by the reference provider's. +/// The reference verifier is reachable only from decryption files, so its calls show those checks ran. +#[rstest] +#[case::reference_accepts(false, true)] +#[case::own_rejects(true, false)] +fn decryption_file_signatures_are_verified(#[case] own_rejects: bool, #[case] passes: bool) { + let calls = Arc::new(AtomicUsize::new(0)); + let verifiers = |builder: ProviderBuilder| { + SIGNATURES[..8].iter().fold(builder, |builder, &algorithm| { + builder.with(Entry::SignatureVerifier(Arc::new(EchoVerifier { + algorithm, + calls: Arc::clone(&calls), + }))) + }) + }; + let loader = Entry::PrivateKeyLoader(Arc::new(EchoLoader { wrong: own_rejects })); + let mut options = Options::default(); + let provider = if own_rejects { + verifiers(CryptoProvider::builder().with(loader)).build().unwrap() + } else { + options.reference_provider = Some(Box::leak(Box::new( + verifiers(CryptoProvider::builder()).build().unwrap(), + ))); + CryptoProvider::builder().with(loader).build().unwrap() + }; + let mut c = Checks::default(); + inconsistent_rsa(&mut c, &provider, options); + // A rejected signature fails both the base key's positive control and each derived key's own check. + for (file, _) in RSA_DECRYPT_FILES { + let failures: Vec<_> = c.failures.iter().filter(|f| f.starts_with(file)).collect(); + for positive in [true, false] { + let failed = failures + .iter() + .any(|f| f.contains("/positive verification:") == positive); + assert_eq!(failed, !passes, "{file}: {failures:?}"); + } + } + assert!(calls.load(Ordering::Relaxed) > 0); +} + +/// Without any verifier, the base key's and the derived key's decryption-file signature checks each fail, even when the signatures agree. +#[test] +fn unverifiable_decryption_file_signatures_fail() { + let (file, _) = RSA_DECRYPT_FILES[0]; + let corpus = v::wycheproof(file); + let test = &v::tests(&corpus.test_groups[0])[0]; + let key = SigningKey { signature: vec![0] }; + let mut c = Checks::default(); + inconsistent_signatures( + &mut c, + &CryptoProvider::builder().build().unwrap(), + Options::default(), + (&key, &key), + "unverifiable", + SigningInputs::Messages { + file, + test, + public: &[], + }, + ); + for positive in [true, false] { + let unverified = c + .failures + .iter() + .filter(|f| f.contains("no verifier in the provider") && f.contains("/positive verification:") == positive) + .count(); + assert_eq!(unverified, PARTIAL.len(), "{:?}", c.failures); + } +} diff --git a/picky-crypto/CONTRACT.md b/picky-crypto/CONTRACT.md index 8db0372a..7c53b781 100644 --- a/picky-crypto/CONTRACT.md +++ b/picky-crypto/CONTRACT.md @@ -836,6 +836,7 @@ For each of them, conformance tests assert that nothing panics and that the resu The expected plaintext is the published one for a published ciphertext, and the plaintext the suite encrypted for the other two sources. The check is never skipped: it fails if no source produces a ciphertext. Keys derived from base keys published only with signature vectors get decryption checks in the same way. + Keys derived from base keys published only with decryption vectors get signing checks on those vectors' published messages: with no published signature to pin the base key, a signature passes only if the provider's verifier, or else the conformance runner's reference verifier from another backend in the same build, accepts it, and the check fails if neither exists. Decryption returns [`Error::InvalidKey`] or the expected plaintext. The suite uses only published ciphertexts labeled valid, so every ciphertext from any of the three sources has valid padding, and [`Error::VerificationFailed`] is a conformance failure for it, because a decryption correct under (`n`, `e`) recovers the valid padding. Each backend is checked on its own; differential runs do not repeat this check or compare outcomes between backends. From 001b5a9051c982814a8ddd609802b756999bf7f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20CORTIER?= Date: Thu, 15 Oct 2026 16:30:20 +0900 Subject: [PATCH 30/30] feat(picky-crypto): add a random feature to the RustCrypto backend The `random` feature provides the operating-system secure-random entry on its own, for code that needs only randomness. The families that draw randomness (`aead`, `rsa`, `nist-ec`, `curve25519`) enable it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- picky-crypto-rustcrypto/Cargo.toml | 11 ++++++----- picky-crypto-rustcrypto/README.md | 11 ++++++----- picky-crypto-rustcrypto/src/lib.rs | 11 +++-------- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/picky-crypto-rustcrypto/Cargo.toml b/picky-crypto-rustcrypto/Cargo.toml index 768c944b..e239b7c1 100644 --- a/picky-crypto-rustcrypto/Cargo.toml +++ b/picky-crypto-rustcrypto/Cargo.toml @@ -13,15 +13,16 @@ keywords = ["cryptography", "provider", "crypto"] categories = ["cryptography"] [features] +random = ["dep:getrandom"] digest = ["dep:sha1", "dep:sha2", "dep:sha3", "dep:hmac", "dep:pbkdf2", "dep:digest", "dep:crypto-common"] aes = ["dep:aes", "dep:cbc", "dep:cipher"] -aead = ["dep:aes-gcm", "dep:aes", "dep:getrandom", "dep:crypto-common"] +aead = ["random", "dep:aes-gcm", "dep:aes", "dep:crypto-common"] key-wrap = ["dep:aes-kw", "dep:aes"] -rsa = ["dep:rsa", "dep:md-5", "dep:sha1", "dep:sha2", "dep:sha3", "dep:getrandom", "dep:chacha20", "dep:crypto-common"] -nist-ec = ["dep:p256", "dep:p384", "dep:p521", "dep:sha2", "dep:hmac", "dep:getrandom", "dep:crypto-common", "dep:sec1"] -curve25519 = ["dep:ed25519-dalek", "dep:ed25519", "dep:x25519-dalek", "dep:curve25519-dalek", "dep:sha2", "dep:getrandom", "dep:crypto-common"] +rsa = ["random", "dep:rsa", "dep:md-5", "dep:sha1", "dep:sha2", "dep:sha3", "dep:chacha20", "dep:crypto-common"] +nist-ec = ["random", "dep:p256", "dep:p384", "dep:p521", "dep:sha2", "dep:hmac", "dep:crypto-common", "dep:sec1"] +curve25519 = ["random", "dep:ed25519-dalek", "dep:ed25519", "dep:x25519-dalek", "dep:curve25519-dalek", "dep:sha2", "dep:crypto-common"] legacy = ["dep:md4", "dep:md-5", "dep:rc4", "dep:des", "dep:rc2", "dep:cbc", "dep:cipher", "dep:digest"] -all = ["digest", "aes", "aead", "key-wrap", "rsa", "nist-ec", "curve25519", "legacy"] +all = ["random", "digest", "aes", "aead", "key-wrap", "rsa", "nist-ec", "curve25519", "legacy"] [dependencies] picky-crypto = { path = "../picky-crypto" } diff --git a/picky-crypto-rustcrypto/README.md b/picky-crypto-rustcrypto/README.md index 4e70c014..00b6d4e5 100644 --- a/picky-crypto-rustcrypto/README.md +++ b/picky-crypto-rustcrypto/README.md @@ -10,18 +10,19 @@ Parsing and encoding formats need no provider; only code that performs cryptogra ## Features and the library The library consists of the crates below; `Cargo.lock` records their versions. -Dependencies needed internally do not enable additional provider entries. +Dependencies needed internally do not enable additional provider entries; families that draw randomness enable `random`, because they use its generator. MAC, CBC, AES-GCM and AES-KW entries support both applying and processing protection. | Feature | Provider entries | Library crates | |---|---|---| +| `random` | Secure random from the operating-system generator | getrandom | | `digest` | SHA-1/224/256/384/512, SHA3-384/512; HMAC-SHA-1/224/256/384/512; PBKDF2 with those HMACs | sha1, sha2, sha3, digest, hmac, pbkdf2 | | `aes` | AES-128/192/256-CBC without padding | aes, cbc, cipher | -| `aead` | AES-128/192/256-GCM; secure random | aes-gcm, aes, getrandom | +| `aead` | AES-128/192/256-GCM; enables `random` | aes-gcm, aes | | `key-wrap` | AES-128/192/256-KW | aes-kw, aes | -| `rsa` | PKCS#1 v1.5 verification with MD5, SHA-1/224/256/384/512 and SHA3-384/512; PKCS#1 v1.5 and OAEP-SHA-1/256 encryption; RSA private-key loading and 2048/3072/4096-bit generation; secure random | rsa, md-5, sha1, sha2, sha3, getrandom, chacha20 | -| `nist-ec` | P-256/SHA-256, P-384/SHA-384 and P-521/SHA-512 ECDSA verification; ECDH, private-key loading and generation for those curves; secure random | p256, p384, p521, sha2, hmac, getrandom | -| `curve25519` | Ed25519 verification, private-key loading and generation; X25519 ephemeral agreement and private-key loading; secure random | ed25519-dalek, ed25519, x25519-dalek, sha2, getrandom | +| `rsa` | PKCS#1 v1.5 verification with MD5, SHA-1/224/256/384/512 and SHA3-384/512; PKCS#1 v1.5 and OAEP-SHA-1/256 encryption; RSA private-key loading and 2048/3072/4096-bit generation; enables `random` | rsa, md-5, sha1, sha2, sha3, chacha20 | +| `nist-ec` | P-256/SHA-256, P-384/SHA-384 and P-521/SHA-512 ECDSA verification; ECDH, private-key loading and generation for those curves; enables `random` | p256, p384, p521, sha2, hmac | +| `curve25519` | Ed25519 verification, private-key loading and generation; X25519 ephemeral agreement and private-key loading; enables `random` | ed25519-dalek, ed25519, x25519-dalek, sha2 | | `legacy` | MD4, MD5, RC4, 3DES-EDE3-CBC and RC2-CBC | md4, md-5, digest, rc4, des, rc2, cbc, cipher | | `all` | Every family above | Every dependency above | diff --git a/picky-crypto-rustcrypto/src/lib.rs b/picky-crypto-rustcrypto/src/lib.rs index eb59261f..9f7f29fb 100644 --- a/picky-crypto-rustcrypto/src/lib.rs +++ b/picky-crypto-rustcrypto/src/lib.rs @@ -28,7 +28,7 @@ mod key_wrap; mod mac; #[cfg(feature = "digest")] mod pbkdf2; -#[cfg(any(feature = "aead", feature = "rsa", feature = "nist-ec", feature = "curve25519"))] +#[cfg(feature = "random")] mod random; #[cfg(feature = "legacy")] mod rc4; @@ -67,7 +67,7 @@ pub fn provider() -> picky_crypto::CryptoProvider { let builder = curve25519::entries(builder); #[cfg(feature = "legacy")] let builder = rc4::entries(builder); - #[cfg(any(feature = "aead", feature = "rsa", feature = "nist-ec", feature = "curve25519"))] + #[cfg(feature = "random")] let builder = builder.with(picky_crypto::Entry::SecureRandom(std::sync::Arc::new(random::Random))); builder.build().expect("fixed, distinct RustCrypto entries") } @@ -85,12 +85,7 @@ mod tests { + 12 * usize::from(cfg!(feature = "nist-ec")) + 5 * usize::from(cfg!(feature = "curve25519")) + 5 * usize::from(cfg!(feature = "legacy")) - + usize::from(cfg!(any( - feature = "aead", - feature = "rsa", - feature = "nist-ec", - feature = "curve25519" - ))); + + usize::from(cfg!(feature = "random")); assert_eq!(provider.entries().count(), expected); assert!(!provider.fips()); }