Skip to content

Commit be8220a

Browse files
rayketchamclaude
andcommitted
security: feature-gate ML-DSA / SLH-DSA behind pq-experimental (#72)
The ml-dsa 0.0.4 crate carries open timing-side-channel and implementation advisories (RUSTSEC-2025-0144, CVE-2026-22705, CVE-2026-24850, GHSA-h37v-hp6w-2pp8). Default builds now resolve without ml-dsa at all — operators who want post-quantum signing opt in explicitly: cargo build --features pq-experimental Changes: - Cargo.toml: ml-dsa = { ..., optional = true }; add pq-experimental feature - signer.rs: cfg-gate ml-dsa import, PrivateKey::MlDsa* variants, and every match arm / parse branch that references them; error message conditional - pkcs7/builder.rs: cfg-gate SigningAlgorithm::MlDsa* + SlhDsa* variants, algorithm_id / digest_algorithm arms, and the six PQC unit tests - tests/pq_feature_gate.rs: compile-time + dep-tree assertions that pin the gate — exhaustive matches break the build if a variant leaks, and `cargo tree --no-default-features --invert ml-dsa` must exit non-zero - deny.toml: drop the ml-dsa CVE ignores (default graph is ml-dsa-free) - ci.yml / daily-health.yml / release.yml: keep --ignore RUSTSEC-2025-0144 for cargo audit (Cargo.lock still pins the optional dep, audit walks the lockfile regardless of features) with clarifying rationale - README / CHANGELOG: document the feature flag and mark #72 shipped closes #72 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 09e7f02 commit be8220a

10 files changed

Lines changed: 257 additions & 32 deletions

File tree

.github/workflows/ci.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,11 @@ jobs:
281281
git config --unset-all http.https://github.com/.extraheader || true
282282
git config --global --unset-all http.https://github.com/.extraheader || true
283283
for attempt in 1 2 3; do
284-
cargo audit --ignore RUSTSEC-2023-0071 --ignore RUSTSEC-2025-0144 --ignore RUSTSEC-2025-0134 --ignore CVE-2026-22705 --ignore CVE-2026-24850 --ignore GHSA-h37v-hp6w-2pp8 && break
284+
# RUSTSEC-2025-0144 (ml-dsa timing) only affects `pq-experimental`
285+
# builds — ml-dsa is feature-gated (gh #72) and absent from the
286+
# default dep graph (verified by pq_feature_gate.rs). Ignored in
287+
# audit because Cargo.lock still pins the crate for the opt-in feature.
288+
cargo audit --ignore RUSTSEC-2023-0071 --ignore RUSTSEC-2025-0134 --ignore RUSTSEC-2025-0144 && break
285289
echo "Attempt $attempt failed, retrying..."
286290
sleep 5
287291
done

.github/workflows/daily-health.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,8 @@ jobs:
117117
cargo audit 2>&1 || true
118118
echo ""
119119
echo "=== Audit with accepted ignores ==="
120-
cargo audit --ignore RUSTSEC-2023-0071 --ignore RUSTSEC-2025-0144 --ignore RUSTSEC-2025-0134
120+
# RUSTSEC-2025-0144 = ml-dsa timing; pq-experimental-only (gh #72)
121+
cargo audit --ignore RUSTSEC-2023-0071 --ignore RUSTSEC-2025-0134 --ignore RUSTSEC-2025-0144
121122
122123
- name: Run cargo deny
123124
env:

.github/workflows/release.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,17 @@ jobs:
4141
- name: Install cargo-audit
4242
run: cargo install --locked cargo-audit
4343
- name: cargo audit (RustSec advisories)
44-
run: cargo audit --deny warnings
44+
# Ignores must match ci.yml + daily-health.yml:
45+
# RUSTSEC-2023-0071 — rsa Marvin attack, oaep_decrypt exists but is not web-reachable (covered by security regression test)
46+
# RUSTSEC-2025-0134 — rustls-pemfile unmaintained, transitive via reqwest/rustls
47+
# RUSTSEC-2025-0144 — ml-dsa timing; pq-experimental-only (gh #72), absent from default dep graph
48+
# RUSTSEC-2026-0097 — rand unsound-with-custom-logger, transitive via rsa/quinn/p12-keystore, no custom logger used
49+
run: >-
50+
cargo audit --deny warnings
51+
--ignore RUSTSEC-2023-0071
52+
--ignore RUSTSEC-2025-0134
53+
--ignore RUSTSEC-2025-0144
54+
--ignore RUSTSEC-2026-0097
4555
- name: Install cargo-deny
4656
run: cargo install --locked cargo-deny
4757
- name: cargo deny (advisories, licenses, bans, sources)

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- **Multipart streaming body-limit now returns 413** — uploads without a `Content-Length` header (chunked transfer-encoding) that exceed `max_upload_size` are rejected with `413 Payload Too Large` instead of `500 Internal Server Error`. The fix walks the multipart error source chain for `http_body_util::LengthLimitError` and maps it to `SignError::FileTooLarge`, closing the 500-leakage gap the pre-buffer layer left behind.
1212
- **Release ci-gate now runs cargo-audit + cargo-deny** — vulnerable dependencies can no longer ship via the release workflow, even if admin-bypass is used on `main`.
1313
- **Cosign signing failures now fail the release** — removed `|| echo` fallback on `gh release upload` and added a post-sign presence check for every `.sig`/`.cosign-bundle`. v0.5.8 shipped without supply-chain signatures because of this silent-fail; regression guard added.
14-
- **ml-dsa CVE cluster acknowledged in deny.toml + cargo audit**CVE-2026-22705, CVE-2026-24850, GHSA-h37v-hp6w-2pp8 explicitly ignored with tracker link; no CI surprise when advisory DB catches up.
14+
- **ML-DSA / SLH-DSA moved behind `pq-experimental` feature flag (#72)**the `ml-dsa` crate and all `PrivateKey::MlDsa*` / `SigningAlgorithm::MlDsa*` / `SigningAlgorithm::SlhDsa*` variants are now gated. The default build does not resolve `ml-dsa`, which removes RUSTSEC-2025-0144 (timing side-channel), CVE-2026-22705, CVE-2026-24850, and GHSA-h37v-hp6w-2pp8 from the default dep graph. `tests/pq_feature_gate.rs` asserts the invariant at CI time via exhaustive match + `cargo tree --no-default-features --invert ml-dsa`.
1515
- **rustls-webpki CVE floor regression test** — supply-chain test asserts locked version >= 0.103.12.
1616

1717
### Fixed
@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2424
- `test_exact_boundary_content_length_not_413` — off-by-one regression on `body == max_upload_size`.
2525
- `tests/supply_chain_regression.rs` — Cargo.lock version-floor assertions.
2626
- `tests/release_assets.rs` — README/release.yml asset-name drift guard.
27+
- `tests/pq_feature_gate.rs` — compile-time + dep-tree assertions for the `pq-experimental` gate.
2728

2829
## [0.5.8] - 2026-04-16
2930

README.md

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ No OpenSSL. No `signtool.exe`. No external dependencies. One binary.
6363
- **Detached CMS/PKCS#7** --- Sign any file with a `.p7s` detached signature
6464
- **PowerShell signing** --- PS1 scripts with Base64 PKCS#7 signature blocks
6565
- **RFC 3161 timestamping** --- Counter-signatures for long-term validity
66-
- **Multi-algorithm** --- RSA (2048-4096), ECDSA P-256/P-384/P-521, Ed25519, ML-DSA-44/65/87
66+
- **Multi-algorithm** --- RSA (2048-4096), ECDSA P-256/P-384/P-521, Ed25519. Post-quantum (ML-DSA-44/65/87) opt-in via `--features pq-experimental`.
6767
- **Signature verification** --- Validate Authenticode and detached CMS signatures
6868
- **PFX/PKCS#12 import** --- Load signing credentials from `.pfx` files
6969
- **Web service mode** --- REST API for Code Signing as a Service
@@ -246,6 +246,7 @@ server = "CN=Server Signers,OU=Groups,DC=corp,DC=example,DC=com"
246246
|--------|------|-------------|
247247
| `POST` | `/api/v1/sign` | Upload and sign a file (multipart) |
248248
| `POST` | `/api/v1/sign-detached` | Create detached CMS signature |
249+
| `POST` | `/api/v1/sign-batch` | Sign up to 10 files, return a ZIP + `signing_summary.csv` |
249250
| `POST` | `/api/v1/verify` | Verify an Authenticode signature |
250251
| `POST` | `/api/v1/verify-detached` | Verify a detached signature |
251252
| `GET` | `/api/v1/status` | Server status and statistics |
@@ -284,6 +285,26 @@ curl -X POST https://sign.example.com/api/v1/sign \
284285

285286
</details>
286287

288+
<details>
289+
<summary><strong>Batch sign (curl)</strong></summary>
290+
291+
```bash
292+
# Upload up to 10 files in a single request; response is a ZIP archive
293+
curl -X POST https://sign.example.com/api/v1/sign-batch \
294+
-H "X-Remote-User: jdoe" \
295+
-F "file=@app1.exe" \
296+
-F "file=@app2.dll" \
297+
-F "file=@installer.msi" \
298+
-F "cert_type=desktop" \
299+
-o signed-batch.zip
300+
301+
# The ZIP contains signed_<name> for each input plus signing_summary.csv
302+
# with columns: file, status, algorithm, timestamped, duration_ms, error
303+
unzip -l signed-batch.zip
304+
```
305+
306+
</details>
307+
287308
<details>
288309
<summary><strong>Detached signature (curl)</strong></summary>
289310

@@ -465,7 +486,7 @@ COMMANDS:
465486

466487
## Security
467488

468-
- **No OpenSSL** --- Pure Rust crypto stack (`rsa`, `p256`, `p384`, `p521`, `ed25519-dalek`, `ml-dsa`, `sha2`, `aes-gcm`). TLS via `rustls` with `aws-lc-rs` backend.
489+
- **No OpenSSL** --- Pure Rust crypto stack (`rsa`, `p256`, `p384`, `p521`, `ed25519-dalek`, `sha2`, `aes-gcm`; `ml-dsa` only with `--features pq-experimental`). TLS via `rustls` with `aws-lc-rs` backend.
469490
- **OpenSSL banned** --- `cargo-deny` blocks `openssl`, `openssl-sys`, and `native-tls` crate usage.
470491
- **Key zeroization** --- Private keys wrapped in `Zeroizing<>` for secure memory cleanup.
471492
- **Audit trail** --- Every sign/verify operation logged with request ID, file hash, signer, client IP, timestamp status, and duration.
@@ -536,9 +557,9 @@ The project is stable for Authenticode, detached CMS, and RFC 3161 signing workl
536557

537558
### v0.6 --- PQ-experimental opt-in + structural clean-up
538559

539-
- **Feature-gate ML-DSA behind `pq-experimental`** ([#72](https://github.com/rayketcham-lab/PKI-Signing-Service/issues/72)) --- default builds drop the `ml-dsa` / `slh-dsa` dependencies entirely. Operators who want PQC opt in explicitly with `cargo build --features pq-experimental`.
560+
- **Feature-gate ML-DSA behind `pq-experimental`** ([#72](https://github.com/rayketcham-lab/PKI-Signing-Service/issues/72)) --- **shipped** in the current `main`. Default builds drop the `ml-dsa` / `slh-dsa` dependencies entirely; post-quantum opt-in via `cargo build --features pq-experimental`. The `default_build_cargo_tree_omits_ml_dsa` test pins the invariant.
540561
- **Decompose `signer.rs` / `verifier.rs` monoliths** ([#55](https://github.com/rayketcham-lab/PKI-Signing-Service/issues/55)) --- extract PFX loading and cert-validation helpers into dedicated modules without public-API churn.
541-
- **ML-DSA timing-side-channel tracking** ([#42](https://github.com/rayketcham-lab/PKI-Signing-Service/issues/42)) --- follow the upstream `ml-dsa` 0.0.4 → stable release and drop the `cargo-audit` ignores as soon as a constant-time Decompose lands.
562+
- **ML-DSA timing-side-channel tracking** ([#42](https://github.com/rayketcham-lab/PKI-Signing-Service/issues/42)) --- follow the upstream `ml-dsa` 0.0.4 → stable release and drop the `pq-experimental`-only `cargo-audit` ignores as soon as a constant-time Decompose lands.
542563

543564
### v0.7 --- Hybrid / composite certificates
544565

crates/pki-sign/Cargo.toml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ p256 = { version = "0.13", features = ["ecdsa", "pem", "ecdh"] }
5252
p384 = { version = "0.13", features = ["ecdsa", "pem", "ecdh"] }
5353
p521 = { version = "0.13", features = ["ecdsa", "pem", "ecdh"] }
5454
ed25519-dalek = { version = "2", features = ["pkcs8"] }
55-
ml-dsa = { version = "0.0.4", features = ["pkcs8"] }
55+
ml-dsa = { version = "0.0.4", features = ["pkcs8"], optional = true }
5656
signature = "2"
5757
pkcs8 = { version = "0.10", features = ["pem"] }
5858
hkdf = "0.12"
@@ -106,6 +106,13 @@ http-body-util = "0.1"
106106

107107
[features]
108108
default = []
109+
# Experimental post-quantum signing (ML-DSA / SLH-DSA).
110+
#
111+
# Default-off because the ml-dsa 0.0.4 crate is pre-1.0 and carries open
112+
# timing-side-channel / implementation advisories (RUSTSEC-2025-0144,
113+
# CVE-2026-22705, CVE-2026-24850, GHSA-h37v-hp6w-2pp8). Gating the surface
114+
# keeps those CVEs out of the default build's dep graph entirely.
115+
pq-experimental = ["dep:ml-dsa"]
109116

110117
[dev-dependencies]
111118
tempfile = { workspace = true }

crates/pki-sign/src/pkcs7/builder.rs

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,16 +111,22 @@ pub enum SigningAlgorithm {
111111
/// Ed25519 (RFC 8410/8419 — pure EdDSA, no separate digest).
112112
Ed25519,
113113
/// ML-DSA-44 (FIPS 204, RFC 9882 — pure scheme, SHA-256 for CMS digest).
114+
#[cfg(feature = "pq-experimental")]
114115
MlDsa44,
115116
/// ML-DSA-65 (FIPS 204, RFC 9882 — pure scheme, SHA-512 for CMS digest).
117+
#[cfg(feature = "pq-experimental")]
116118
MlDsa65,
117119
/// ML-DSA-87 (FIPS 204, RFC 9882 — pure scheme, SHA-512 for CMS digest).
120+
#[cfg(feature = "pq-experimental")]
118121
MlDsa87,
119122
/// SLH-DSA-SHA2-128s (FIPS 205, RFC 9909 — pure scheme, SHA-256 for CMS digest).
123+
#[cfg(feature = "pq-experimental")]
120124
SlhDsaSha2128s,
121125
/// SLH-DSA-SHA2-192s (FIPS 205, RFC 9909 — pure scheme, SHA-512 for CMS digest).
126+
#[cfg(feature = "pq-experimental")]
122127
SlhDsaSha2192s,
123128
/// SLH-DSA-SHA2-256s (FIPS 205, RFC 9909 — pure scheme, SHA-512 for CMS digest).
129+
#[cfg(feature = "pq-experimental")]
124130
SlhDsaSha2256s,
125131
}
126132

@@ -138,11 +144,17 @@ impl SigningAlgorithm {
138144
SigningAlgorithm::EcdsaSha384 => &asn1::ECDSA_WITH_SHA384_ALGORITHM_ID,
139145
SigningAlgorithm::EcdsaSha512 => &asn1::ECDSA_WITH_SHA512_ALGORITHM_ID,
140146
SigningAlgorithm::Ed25519 => &asn1::ED25519_ALGORITHM_ID,
147+
#[cfg(feature = "pq-experimental")]
141148
SigningAlgorithm::MlDsa44 => &asn1::ML_DSA_44_ALGORITHM_ID,
149+
#[cfg(feature = "pq-experimental")]
142150
SigningAlgorithm::MlDsa65 => &asn1::ML_DSA_65_ALGORITHM_ID,
151+
#[cfg(feature = "pq-experimental")]
143152
SigningAlgorithm::MlDsa87 => &asn1::ML_DSA_87_ALGORITHM_ID,
153+
#[cfg(feature = "pq-experimental")]
144154
SigningAlgorithm::SlhDsaSha2128s => &asn1::SLH_DSA_SHA2_128S_ALGORITHM_ID,
155+
#[cfg(feature = "pq-experimental")]
145156
SigningAlgorithm::SlhDsaSha2192s => &asn1::SLH_DSA_SHA2_192S_ALGORITHM_ID,
157+
#[cfg(feature = "pq-experimental")]
146158
SigningAlgorithm::SlhDsaSha2256s => &asn1::SLH_DSA_SHA2_256S_ALGORITHM_ID,
147159
}
148160
}
@@ -156,17 +168,18 @@ impl SigningAlgorithm {
156168
match self {
157169
SigningAlgorithm::RsaSha256
158170
| SigningAlgorithm::RsaPssSha256
159-
| SigningAlgorithm::EcdsaSha256
160-
| SigningAlgorithm::MlDsa44
161-
| SigningAlgorithm::SlhDsaSha2128s => DigestAlgorithm::Sha256,
171+
| SigningAlgorithm::EcdsaSha256 => DigestAlgorithm::Sha256,
162172
SigningAlgorithm::RsaSha384
163173
| SigningAlgorithm::RsaPssSha384
164174
| SigningAlgorithm::EcdsaSha384 => DigestAlgorithm::Sha384,
165175
SigningAlgorithm::RsaSha512
166176
| SigningAlgorithm::RsaPssSha512
167177
| SigningAlgorithm::EcdsaSha512
168-
| SigningAlgorithm::Ed25519
169-
| SigningAlgorithm::MlDsa65
178+
| SigningAlgorithm::Ed25519 => DigestAlgorithm::Sha512,
179+
#[cfg(feature = "pq-experimental")]
180+
SigningAlgorithm::MlDsa44 | SigningAlgorithm::SlhDsaSha2128s => DigestAlgorithm::Sha256,
181+
#[cfg(feature = "pq-experimental")]
182+
SigningAlgorithm::MlDsa65
170183
| SigningAlgorithm::MlDsa87
171184
| SigningAlgorithm::SlhDsaSha2192s
172185
| SigningAlgorithm::SlhDsaSha2256s => DigestAlgorithm::Sha512,
@@ -2822,7 +2835,9 @@ mod tests {
28222835
}
28232836

28242837
// ─── PQC Signing Algorithm Tests (RFC 9882 / RFC 9909) ───
2838+
// Gated behind `pq-experimental` — default builds don't expose these variants.
28252839

2840+
#[cfg(feature = "pq-experimental")]
28262841
#[test]
28272842
fn test_signing_algorithm_ml_dsa_44() {
28282843
let alg = SigningAlgorithm::MlDsa44;
@@ -2833,6 +2848,7 @@ mod tests {
28332848
assert_eq!(id[12], 0x11); // ML-DSA-44 OID last byte
28342849
}
28352850

2851+
#[cfg(feature = "pq-experimental")]
28362852
#[test]
28372853
fn test_signing_algorithm_ml_dsa_65() {
28382854
let alg = SigningAlgorithm::MlDsa65;
@@ -2841,6 +2857,7 @@ mod tests {
28412857
assert_eq!(id[12], 0x12); // ML-DSA-65 OID last byte
28422858
}
28432859

2860+
#[cfg(feature = "pq-experimental")]
28442861
#[test]
28452862
fn test_signing_algorithm_ml_dsa_87() {
28462863
let alg = SigningAlgorithm::MlDsa87;
@@ -2849,6 +2866,7 @@ mod tests {
28492866
assert_eq!(id[12], 0x13); // ML-DSA-87 OID last byte
28502867
}
28512868

2869+
#[cfg(feature = "pq-experimental")]
28522870
#[test]
28532871
fn test_signing_algorithm_slh_dsa_128s() {
28542872
let alg = SigningAlgorithm::SlhDsaSha2128s;
@@ -2857,6 +2875,7 @@ mod tests {
28572875
assert_eq!(id[12], 0x14); // SLH-DSA-SHA2-128s OID last byte
28582876
}
28592877

2878+
#[cfg(feature = "pq-experimental")]
28602879
#[test]
28612880
fn test_signing_algorithm_slh_dsa_192s() {
28622881
let alg = SigningAlgorithm::SlhDsaSha2192s;
@@ -2865,6 +2884,7 @@ mod tests {
28652884
assert_eq!(id[12], 0x16); // SLH-DSA-SHA2-192s OID last byte
28662885
}
28672886

2887+
#[cfg(feature = "pq-experimental")]
28682888
#[test]
28692889
fn test_signing_algorithm_slh_dsa_256s() {
28702890
let alg = SigningAlgorithm::SlhDsaSha2256s;
@@ -2873,6 +2893,7 @@ mod tests {
28732893
assert_eq!(id[12], 0x18); // SLH-DSA-SHA2-256s OID last byte
28742894
}
28752895

2896+
#[cfg(feature = "pq-experimental")]
28762897
#[test]
28772898
fn test_signed_data_builder_ml_dsa_44_signer() {
28782899
let cert = build_test_cert(100, "PQC-Signer");
@@ -2903,6 +2924,7 @@ mod tests {
29032924
);
29042925
}
29052926

2927+
#[cfg(feature = "pq-experimental")]
29062928
#[test]
29072929
fn test_cms_alg_protection_ml_dsa_87() {
29082930
let attr = build_cms_algorithm_protection_attr_ex(

0 commit comments

Comments
 (0)