From 8bea20813db33a57f3521c5a5e205a8a3508d35d Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Fri, 4 Sep 2026 06:35:38 -0400 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20truth=20pass=20=E2=80=94=20every=20?= =?UTF-8?q?claim=20matches=20the=20code,=20and=20a=20live=20guard=20keeps?= =?UTF-8?q?=20it=20that=20way?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merged follow-through from two independent reviews (the multi-agent security audit and an external product review), scoped strictly inside the 1.x format freeze: docs, metadata, and one XML-docs addition. No byte meaning, reader acceptance, or API change. Resurrect the docs-consistency guard: - .github/workflows/docs-consistency.yml now exists — the workflow the 1.7.0 changelog advertised but never shipped. It runs check-docs-consistency.sh on every push and PR. - The script is portable (no GNU-only grep -P, so it runs on macOS) and now also checks the SECURITY.md supported-versions cell, the ROADMAP-2.0 "Today" cell, the AUDIT-SCOPE pinned tag, artifact-name examples, release-download / reproducibility / clone-at-tag commands, and install snippets in all Markdown (CHANGELOG excluded as history). Verified green on this tree and on macOS. Version sweep to 1.7.1 (was scattered across 1.0.1–1.7.0): SECURITY.md table, ROADMAP "Now" heading, KNOWN-GAPS + GOLD-STANDARD review markers, ROADMAP-2.0 Today cell, AUDIT-SCOPE pinned tag (v1.7.1 / e0d332a), README + SUPPLY-CHAIN + REPRODUCIBLE-BUILDS worked examples, ANNOUNCE snippets, CHANGELOG compare-link footer (adds 1.7.0/1.7.1). PackageValidationBaselineVersion 1.5.0 → 1.7.1 (pack verified against the published baseline). PackageReleaseNotes reworded version-agnostic so they cannot silently go stale on nuget.org. Recalibrate claims to what the code does and tests pin: - SECURITY.md: no-oracle bullet now states the true property (one exception type; byte-identical messages for key-dependent failures; structural diagnostics are key-independent); integrity scoped to the authenticated envelope (trailing bytes after the final frame are ignored — KNOWN-GAPS); bounded-work notes tight ceilings are opt-in via PqDecryptionLimits.Untrusted; Dependencies now names Signing's BouncyCastle use and the Rust demo core's pre-1.0 RustCrypto crates; SBOM list covers all eight per-package SBOMs. - SUPPLY-CHAIN.md: artifact table lists all nine packages; snupkg files are documented as NOT attested (verifying one fails by construction); the reproducibility script's 4-of-9 package coverage is stated; "every push" heading acknowledges scheduled jobs. - GOLD-STANDARD.md: binary-compat row downgraded to ⚠️ — validation is overridden off in Aws/AzureKeyVault/Gcp/Analyzers (stale pre-first-publish gate); PublicAPI baseline count corrected to seven packages. Correct the rotation story (KEY-MANAGEMENT.md): header-only CEK rewrap cannot ship on format v2 — the header is AAD for every frame, so a rewrap re-tags every chunk, and doing so under the same CEK and nonces with new AAD emits second GCM tags for existing (key, nonce) pairs while old copies coexist, making the GCM authentication key recoverable. Rotation is now documented as a streaming transcode (fresh CEK + nonce prefix, full-file I/O, detached signatures invalidated); true header-only rewrap is banked as a format-v3 candidate. Status list also gains the shipped Gcp provider and multi-recipient moves to past tense. Format-doc errata (doc-only, matching the frozen implementation): - KEY-FILE-FORMAT.md: a PQKF body is a KeySource-1 passphrase container whose KeyParams carry KdfId 1 (PBKDF2) or 2 (Argon2id) — previous text conflated KeySource with KdfId and would misdirect an independent implementer. - FILE-FORMAT.md: KeySource-2 HKDF formulas now state salt = absent. KNOWN-GAPS.md gains five verified entries: header-only rewrap (v3 candidate), IContentKeyProvider key-freshness contract and its GCM consequences, passphrase Unicode normalization, non-interruptible KDF derivation, and default permissions on decrypted output. IContentKeyProvider.WrapNewKeyAsync's XML docs now state why the fresh-key contract is load-bearing (build clean, 0 warnings). CLAUDE.md's version-bump checklist now names every reference class that drifted, so the next release sweep is complete by construction. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sym7RJ7ehNhbMXytE5rMmS --- .github/workflows/docs-consistency.yml | 24 ++++++ CHANGELOG.md | 46 ++++++++++- CLAUDE.md | 11 +++ Directory.Build.props | 2 +- KNOWN-GAPS.md | 40 ++++++++- README.md | 6 +- ROADMAP.md | 2 +- SECURITY.md | 41 +++++++--- docs/ANNOUNCE.md | 4 +- docs/AUDIT-SCOPE.md | 9 +- docs/FILE-FORMAT.md | 4 +- docs/GOLD-STANDARD.md | 6 +- docs/KEY-FILE-FORMAT.md | 7 +- docs/KEY-MANAGEMENT.md | 49 +++++++---- docs/REPRODUCIBLE-BUILDS.md | 10 +-- docs/ROADMAP-2.0.md | 2 +- docs/SUPPLY-CHAIN.md | 31 +++---- samples/Pqfe.Cli/Pqfe.Cli.csproj | 2 +- scripts/check-docs-consistency.sh | 82 +++++++++++++++---- ...ostQuantum.FileEncryption.Analyzers.csproj | 2 +- .../PostQuantum.FileEncryption.Aws.csproj | 2 +- ...uantum.FileEncryption.AzureKeyVault.csproj | 2 +- ...tion.Extensions.DependencyInjection.csproj | 2 +- .../PostQuantum.FileEncryption.Gcp.csproj | 2 +- .../PostQuantum.FileEncryption.Hybrid.csproj | 2 +- .../PostQuantum.FileEncryption.Signing.csproj | 2 +- .../IContentKeyProvider.cs | 8 ++ .../PostQuantum.FileEncryption.csproj | 2 +- 28 files changed, 311 insertions(+), 91 deletions(-) create mode 100644 .github/workflows/docs-consistency.yml diff --git a/.github/workflows/docs-consistency.yml b/.github/workflows/docs-consistency.yml new file mode 100644 index 0000000..7752d01 --- /dev/null +++ b/.github/workflows/docs-consistency.yml @@ -0,0 +1,24 @@ +name: Docs consistency + +# The continuous counterpart to the release workflow's tag-time version gate: catches +# current-version markers, worked artifact examples, and dead internal links drifting on +# main between releases, instead of discovering them at the next tag (or never). See +# scripts/check-docs-consistency.sh for exactly what is enforced and what is deliberately +# left alone (historical mentions). + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + docs-consistency: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check documentation consistency + run: bash scripts/check-docs-consistency.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index b227189..40a87f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,48 @@ and the `.pqfe` v2 container format is frozen for the entire `1.x` line. ## [Unreleased] +### Added + +- **The documentation-consistency CI guard now actually exists.** The `1.7.0` changelog described + a `docs-consistency` workflow running `scripts/check-docs-consistency.sh` on every push and pull + request, but the workflow file was never committed — the guard never ran, which is how the + version markers below drifted. `.github/workflows/docs-consistency.yml` now runs it, the script + is portable (no GNU-only `grep -P`, so it runs on macOS too), and it additionally checks the + SECURITY.md supported-versions cell, the ROADMAP-2.0 "Today" cell, the AUDIT-SCOPE pinned tag, + and artifact-naming examples (`gh attestation verify` / `gh release download` / + reproducibility-script invocations) against the current version. +- **KNOWN-GAPS.md gains five verified entries:** header-only CEK rewrap as a format-v3 candidate + (see below), the `IContentKeyProvider` key-freshness contract and its consequences, passphrase + Unicode normalization, non-interruptible KDF derivation, and default filesystem permissions on + decrypted output. `IContentKeyProvider.WrapNewKeyAsync`'s docs now state why the fresh-key + contract is load-bearing (reuse collapses AES-GCM nonce uniqueness across files). + +### Changed + +- **KEY-MANAGEMENT.md no longer promises header-only CEK rewrap on format v2.** The serialized + header is bound as AAD into every content frame, so replacing the wrapped CEK forces every frame + to be re-authenticated — and doing that under the *same* CEK and nonces would emit second GCM + tags for existing (key, nonce) pairs while old copies of the file still exist, allowing + authentication-key recovery. Rotation on v2 is now documented as a streaming transcode with a + fresh CEK and nonce prefix; true header-only rewrap is banked as a format-v3 candidate. +- **Docs version sweep to `1.7.1`** across SECURITY.md (supported-versions cell), ROADMAP.md, + KNOWN-GAPS.md and GOLD-STANDARD.md review markers, ROADMAP-2.0.md, AUDIT-SCOPE.md (pinned tag), + README / SUPPLY-CHAIN / REPRODUCIBLE-BUILDS verification examples, and ANNOUNCE.md. + `PackageValidationBaselineVersion` moves from `1.5.0` to `1.7.1`, and `PackageReleaseNotes` + are reworded to be version-agnostic so they can no longer silently go stale on nuget.org. +- **SECURITY.md claims recalibrated to exactly what the code does and tests pin:** one exception + type with byte-identical messages for key-dependent failures (structural diagnostics are + key-independent), integrity scoped to the authenticated envelope (trailing bytes after the final + frame are ignored — see KNOWN-GAPS.md), bounded-work noting that tight ceilings are opt-in via + `PqDecryptionLimits.Untrusted`, Dependencies now naming the Signing package's BouncyCastle use + and the Rust demo core's RustCrypto crates, and the SBOM list reflecting all eight per-package + SBOMs. GOLD-STANDARD.md's binary-compatibility row now states that package validation is + overridden off in the Aws/AzureKeyVault/Gcp/Analyzers packages. +- **Format-doc errata (doc-only; no byte or reader change):** KEY-FILE-FORMAT.md now correctly + says a PQKF body is a KeySource-1 passphrase container whose KeyParams carry KdfId 1 + (PBKDF2-HMAC-SHA256) or 2 (Argon2id) — the previous wording conflated KeySource with KdfId — + and FILE-FORMAT.md's KeySource-2 HKDF formulas state `salt = absent` explicitly. + ## [1.7.1] — 2026-08-20 Patch: WASM sample crate upgrades. No format change, no public API change, and no change to any @@ -857,7 +899,9 @@ First release. The **symmetric, passphrase-based engine is production-ready**. - Bounded work on untrusted headers (KDF cost parameters are range-checked). - Derived keys, wrapped secrets, and private keys are zeroed after use. -[Unreleased]: https://github.com/systemslibrarian/postquantum-file-encryption/compare/v1.6.0...HEAD +[Unreleased]: https://github.com/systemslibrarian/postquantum-file-encryption/compare/v1.7.1...HEAD +[1.7.1]: https://github.com/systemslibrarian/postquantum-file-encryption/compare/v1.7.0...v1.7.1 +[1.7.0]: https://github.com/systemslibrarian/postquantum-file-encryption/compare/v1.6.0...v1.7.0 [1.6.0]: https://github.com/systemslibrarian/postquantum-file-encryption/compare/v1.5.0...v1.6.0 [1.5.0]: https://github.com/systemslibrarian/postquantum-file-encryption/compare/v1.4.1...v1.5.0 [1.4.1]: https://github.com/systemslibrarian/postquantum-file-encryption/compare/v1.4.0...v1.4.1 diff --git a/CLAUDE.md b/CLAUDE.md index 5a5183e..71aefeb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -131,6 +131,17 @@ for the *old* version string and update every user-facing reference: `gh attestation verify PostQuantum.FileEncryption.X.Y.Z.nupkg`, `gh release download vX.Y.Z`). - The "NuGet package version → Today" cell in [docs/ROADMAP-2.0.md](docs/ROADMAP-2.0.md). - Add the new `CHANGELOG.md` section and its compare-link footer. +- The `SECURITY.md` supported-versions cell (current: `X.Y.Z`). +- The "## Now — `X.Y.Z`" heading in `ROADMAP.md` and every "Last reviewed against" + marker (`KNOWN-GAPS.md`, `docs/GOLD-STANDARD.md`) — re-review the document, don't just + re-stamp it. +- `PackageValidationBaselineVersion` in `Directory.Build.props` (the previous release becomes + the new baseline). +- The pinned tag in `docs/AUDIT-SCOPE.md` and the worked verification examples in + `docs/SUPPLY-CHAIN.md`, `docs/REPRODUCIBLE-BUILDS.md`, and `docs/ANNOUNCE.md`. + +Most of these are enforced continuously by `scripts/check-docs-consistency.sh` (run by the +`docs-consistency` workflow on every push and PR) — run it locally before tagging. Leave **historical** mentions alone — past changelog entries, compare links, and prose like "shipped `1.3.0`" are facts about earlier releases, not the current version. When in doubt, diff --git a/Directory.Build.props b/Directory.Build.props index 2f1c168..cc63c15 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -18,7 +18,7 @@ are listed per project in CompatibilitySuppressions.xml. --> true - 1.5.0 + 1.7.1 true diff --git a/KNOWN-GAPS.md b/KNOWN-GAPS.md index 96c219c..f246c56 100644 --- a/KNOWN-GAPS.md +++ b/KNOWN-GAPS.md @@ -5,7 +5,7 @@ incomplete, deferred, or imperfect, so that nobody has to discover it by reading or, worse, in production. If you find a gap not listed here, that itself is a gap — please open an issue. -Last reviewed against: **`1.6.0`**. See [ROADMAP.md](ROADMAP.md) for the forward plan. +Last reviewed against: **`1.7.1`**. See [ROADMAP.md](ROADMAP.md) for the forward plan. ## Release scope (read this first) @@ -236,6 +236,44 @@ Last reviewed against: **`1.6.0`**. See [ROADMAP.md](ROADMAP.md) for the forward inputs, prefer the file APIs (temp-file staging) or the non-atomic stream overload with a bounded destination. This is documented on the method; noted here for completeness. +- **Header-only CEK rewrap cannot ship on format v2.** The serialized header is bound as AAD + into every content frame, so replacing the wrapped CEK changes every frame's AAD and forces + every frame to be re-authenticated. Re-tagging under the *same* CEK and nonces with new AAD + would emit a second AES-GCM tag per (key, nonce) pair — and since old and rotated copies of a + file coexist, an attacker holding both could recover the GCM authentication key from such tag + pairs and forge ciphertext. Safe rotation on v2 is a **streaming transcode** with a fresh CEK + and nonce prefix (bounded memory, full-file I/O — [docs/KEY-MANAGEMENT.md](docs/KEY-MANAGEMENT.md)); + true header-only rewrap needs a format that keeps wrap material outside the chunk-AAD + commitment, a format-v3 candidate. +- **`IContentKeyProvider` cannot verify content-key freshness.** For KeySource 5 the per-file + content key comes from the caller's provider, and cross-file AES-GCM nonce uniqueness rests + entirely on that key being fresh — the on-disk nonce prefix is only 4 random bytes. All + first-party providers draw a fresh random key per call, but a third-party provider that caches + or reuses a data key silently collapses nonce uniqueness to a 32-bit birthday bound across + files (~50% collision odds by ~77k files); colliding files reuse (key, nonce) pairs, leaking + keystream XOR and making the GCM authentication key recoverable. The contract is documented on + `IContentKeyProvider.WrapNewKeyAsync` and in [docs/KEY-MANAGEMENT.md](docs/KEY-MANAGEMENT.md); + an in-band defense (mixing per-file material into the CEK, or a wider nonce prefix) would + change what bytes mean — a format-v3 candidate. +- **Passphrases are UTF-8 encoded with no Unicode normalization.** The same characters typed + through a decomposing input path (e.g. macOS NFD) versus a composed one (Windows NFC) produce + different bytes and a different derived key, surfacing as "wrong passphrase" on a file whose + passphrase the user knows. Normalizing inside the library now would change the derived key for + existing containers (and desync the Rust core), so `1.x` keeps byte-verbatim encoding; callers + accepting cross-platform human input should normalize to NFC before passing the passphrase. + Specifying normalization in the format is a format-v3 candidate. +- **KDF derivation is not interruptible once started.** The platform PBKDF2 one-shot and the + Konscious Argon2id API expose no cancellation, so the `CancellationToken` is honored before and + after — but not during — key derivation; cancelling mid-derivation still pays the full + configured cost. `PqDecryptionLimits` is the real bound on worst-case uncancellable work for + hostile containers. +- **Decrypted output files get default filesystem permissions.** The temp file behind the atomic + write is created with the platform default mode (typically `0644` under a standard umask on + Unix; the directory-inherited ACL on Windows) and the rename **replaces** the destination — so + decrypting over a pre-existing `0600` file leaves the new plaintext at the looser default. + Callers needing tighter permissions should run under a restrictive umask or re-apply the mode + after decryption. + ### Demos - **The .NET demo is Blazor Server, not client-side WebAssembly.** .NET's `AesGcm` is diff --git a/README.md b/README.md index 3df4710..32eb67c 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ None of this library's trust claims ask for faith — each one is checkable in a ```bash # 1. Provenance: the package you downloaded was built by this repo's public release # workflow, on GitHub's runners, from a tagged commit you can read: -gh attestation verify PostQuantum.FileEncryption.1.6.0.nupkg \ +gh attestation verify PostQuantum.FileEncryption.1.7.1.nupkg \ --repo systemslibrarian/postquantum-file-encryption # 2. The frozen format: reproduce the pinned known-answer vectors — byte-exact @@ -603,11 +603,11 @@ Quick verification of any release: ```bash # Verify the build-provenance attestation on a downloaded .nupkg: -gh attestation verify PostQuantum.FileEncryption.1.6.0.nupkg \ +gh attestation verify PostQuantum.FileEncryption.1.7.1.nupkg \ --owner systemslibrarian # Inspect the CycloneDX SBOM bundled with the release: -gh release download v1.6.0 -p 'sbom.core.cdx.json' && jq . sbom.core.cdx.json +gh release download v1.7.1 -p 'sbom.core.cdx.json' && jq . sbom.core.cdx.json # Confirm the conformance vectors decrypt locally: dotnet test --filter "FullyQualifiedName~KnownAnswerVector|FullyQualifiedName~CrossImplementation" diff --git a/ROADMAP.md b/ROADMAP.md index 37eea0c..745d208 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,7 +5,7 @@ what is production-ready, what is deprecated, and what comes after `1.0`. See [KNOWN-GAPS.md](KNOWN-GAPS.md) for the full open-issues ledger and [docs/ROADMAP-v3.md](docs/ROADMAP-v3.md) for the hybrid design. -## Now — `1.7.0` +## Now — `1.7.1` The library ships as a **nine-package lockstep family** — the core, `Hybrid`, `Signing`, the `Aws` / `AzureKeyVault` / `Gcp` KMS adapters, `Extensions.DependencyInjection`, `Analyzers`, diff --git a/SECURITY.md b/SECURITY.md index c0008c6..575be12 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,7 +12,7 @@ any incompatible change requires a `2.0` major version. | Version | Supported | Notes | | ------------------ | --------- | -------------------------------------------------------- | -| `1.x` (current: `1.5.0`) | ✅ | Current line. Security fixes land here. | +| `1.x` (current: `1.7.1`) | ✅ | Current line. Security fixes land here. | | `0.x` | ❌ | Pre-`1.0`. Format was not yet frozen; please upgrade. | A file produced by any `1.x` build is readable by every other `1.x` build. There is no @@ -33,21 +33,30 @@ report until a fix is available and coordinated. - **Confidentiality of file contents** against an attacker who obtains the ciphertext but not the key, using AES-256-GCM with a unique per-file content key. -- **Integrity and authenticity.** Any modification of the ciphertext, header, or framing is - detected and rejected. The authenticated additional data binds the key-establishment - parameters, the chunk ordinal, and the final-chunk marker, defeating bit-flipping, header - tampering, chunk reordering, splicing between containers, and truncation. +- **Integrity and authenticity.** Any modification of the authenticated envelope — the + header, every ciphertext chunk, and the framing through the final frame — is detected and + rejected. The authenticated additional data binds the key-establishment parameters, the + chunk ordinal, and the final-chunk marker, defeating bit-flipping, header tampering, chunk + reordering, splicing between containers, and truncation. (Bytes appended *after* the + authenticated final frame are outside the envelope and are ignored, not rejected — see + [KNOWN-GAPS.md](KNOWN-GAPS.md).) - **Quantum-resistant confidentiality of the data**, via AES-256 (≈128-bit security against a Grover attacker). For quantum-resistant *key establishment* with a hybrid combiner that remains secure if either primitive is later broken, use the companion **`PostQuantum.FileEncryption.Hybrid`** package (X25519 + ML-KEM-768, multi-recipient). - **Bounded work on untrusted input.** KDF cost parameters carried in a container (PBKDF2 - iterations, Argon2id memory/iterations) are range-checked before use, so a malicious - header cannot force unbounded memory or CPU — it fails closed as a `PqFormatException`. -- **No decryption oracle.** Every authentication failure (wrong passphrase, tampered - ciphertext, truncated container, spliced frames) raises the same generic - `PqDecryptionException` with the same message. The library does not distinguish "wrong - key" from "tampered data" at the public surface. + iterations, Argon2id memory/iterations) are range-checked against the format's maxima + before use, so a malicious header cannot force *unbounded* memory or CPU — it fails closed + as a `PqFormatException`. The format maxima are still substantial (up to 2 GiB of Argon2id + memory or 100M PBKDF2 iterations) and the default limits accept every legal container, so + callers decrypting untrusted input should pass `PqDecryptionLimits.Untrusted` (or custom + ceilings) for tight bounds. +- **No decryption oracle.** Every authentication failure raises the same exception type, + `PqDecryptionException`, and every *key-dependent* failure (wrong passphrase vs. tampered + ciphertext) carries one identical message — the library never distinguishes "wrong key" + from "tampered data" at the public surface, and tests pin those messages byte-identical. + Structural failures that an attacker can already compute from the ciphertext alone + (truncation, corrupt framing) carry distinct but key-independent diagnostics. - **No partial output on failure.** File APIs stage every byte to a sibling temp file and only `File.Move` it into place on full success. Stream callers can opt into the same all-or-nothing guarantee via `DecryptAtomicAsync`. @@ -89,6 +98,12 @@ managed, runs anywhere). Removal of the inline mode is targeted for a future maj from .NET's `System.Security.Cryptography`. - The Hybrid package additionally uses `BouncyCastle.Cryptography` for X25519 and ML-KEM, selected so the package runs on every .NET 10 platform without a native ML-KEM dependency. +- The Signing package uses `BouncyCastle.Cryptography` for Ed25519 and ML-DSA-65, under the + same fully-managed rationale as the Hybrid package. +- The Rust/WASM demo core (`samples/pqfe-wasm` — a byte-compatibility reference, not a + shipped NuGet package) uses the pre-1.0 RustCrypto crates (`aes-gcm`, `ml-kem`, `argon2`, + `pbkdf2`, `hkdf`, `sha2`), which are not independently audited; its lockfile is committed + and `cargo-audit` runs in CI. ## Cryptographic design principles @@ -105,7 +120,9 @@ managed, runs anywhere). Removal of the inline mode is targeted for a future maj Every release tag publishes: -- a **CycloneDX SBOM** (`sbom.core.cdx.json`, `sbom.hybrid.cdx.json`), +- **CycloneDX SBOMs, one per package** (`sbom.core.cdx.json`, `sbom.hybrid.cdx.json`, + `sbom.signing.cdx.json`, the three cloud providers, DI extensions, and the tool — see + [docs/SUPPLY-CHAIN.md](docs/SUPPLY-CHAIN.md)), - a **SLSA-style build-provenance attestation** over the `.nupkg` artifacts (verifiable with `gh attestation verify`), - the `.nupkg` files themselves (also published to nuget.org). diff --git a/docs/ANNOUNCE.md b/docs/ANNOUNCE.md index 5dbc438..d3f39c3 100644 --- a/docs/ANNOUNCE.md +++ b/docs/ANNOUNCE.md @@ -87,9 +87,9 @@ A cryptographic library earns trust slowly. The `1.0` release tries to make that ## Try it ```bash -dotnet add package PostQuantum.FileEncryption --version 1.0.1 +dotnet add package PostQuantum.FileEncryption --version 1.7.1 # Add this only if you need public-key (recipient) encryption: -dotnet add package PostQuantum.FileEncryption.Hybrid --version 1.0.1 +dotnet add package PostQuantum.FileEncryption.Hybrid --version 1.7.1 ``` A tiny CLI sample at diff --git a/docs/AUDIT-SCOPE.md b/docs/AUDIT-SCOPE.md index 9fc202e..526d12f 100644 --- a/docs/AUDIT-SCOPE.md +++ b/docs/AUDIT-SCOPE.md @@ -11,7 +11,7 @@ restating. | | | | --- | --- | | Repository | `https://github.com/systemslibrarian/postquantum-file-encryption` | -| Audit revision | latest release tag — **`v1.5.0`**, commit **`878e5a0`** | +| Audit revision | latest release tag — **`v1.7.1`**, commit **`e0d332a`** | | On-disk formats under review | `.pqfe` **v2** container, `.sig` **v1** sidecar, `PQKF` **v1** key file — all **frozen** for the entire `1.x` line | Pin the engagement to the tag, not to `main`: @@ -19,12 +19,13 @@ Pin the engagement to the tag, not to `main`: ```bash git clone https://github.com/systemslibrarian/postquantum-file-encryption cd postquantum-file-encryption -git checkout v1.5.0 # commit 878e5a0 -git verify-tag v1.5.0 # signed tag; confirms provenance +git checkout v1.7.1 # commit e0d332a +# Release provenance: verify the tag's published artifacts against their build-provenance +# attestations instead — see docs/SUPPLY-CHAIN.md ("gh attestation verify"). ``` The formats are frozen ([CLAUDE.md](../CLAUDE.md) "format freeze"), so the bytes an auditor -reads at `v1.5.0` are the bytes every `1.x` build reads. A finding that requires changing +reads at `v1.7.1` are the bytes every `1.x` build reads. A finding that requires changing frozen bytes is a **format-v3 / 2.0** item ([docs/ROADMAP-2.0.md](ROADMAP-2.0.md)), not a point fix — flag it as such. diff --git a/docs/FILE-FORMAT.md b/docs/FILE-FORMAT.md index f1f1005..2419ef7 100644 --- a/docs/FILE-FORMAT.md +++ b/docs/FILE-FORMAT.md @@ -93,12 +93,12 @@ Establishment and recovery: ``` encrypt: (KemCiphertext, ss) = ML-KEM-768.Encapsulate(recipient_public_key) - KEK = HKDF-SHA256(ss, info = "PostQuantum.FileEncryption/v2 ml-kem-768 kek") + KEK = HKDF-SHA256(ss, salt = absent, info = "PostQuantum.FileEncryption/v2 ml-kem-768 kek") (WrappedKey, WrapTag) = AES-256-GCM-Encrypt(KEK, WrapNonce, CEK, aad = "PostQuantum.FileEncryption/v2 cek-wrap") decrypt: ss = ML-KEM-768.Decapsulate(KemCiphertext, recipient_private_key) - KEK = HKDF-SHA256(ss, info = ...) + KEK = HKDF-SHA256(ss, salt = absent, info = ...) CEK = AES-256-GCM-Decrypt(KEK, WrapNonce, WrappedKey, WrapTag, aad = ...) ``` diff --git a/docs/GOLD-STANDARD.md b/docs/GOLD-STANDARD.md index a2df465..a1029ed 100644 --- a/docs/GOLD-STANDARD.md +++ b/docs/GOLD-STANDARD.md @@ -6,7 +6,7 @@ validation — the point of publishing it is that every claim below is checkable is named, and the list of what's *missing* is as load-bearing as the list of what's done. Status legend: ✅ done and verifiable · ⚠️ partial, with the limitation stated · ❌ not -done. Last reviewed against: **`1.7.0`**. +done. Last reviewed against: **`1.7.1`**. ## 1. Cryptographic design @@ -26,8 +26,8 @@ done. Last reviewed against: **`1.7.0`**. | Criterion | Status | Evidence | | --- | --- | --- | | Warnings as errors, latest-recommended analyzers | ✅ | `Directory.Build.props` | -| Public API surface locked against accidental breaks | ✅ | PublicApiAnalyzers, `PublicAPI.Shipped.txt` in all three library packages | -| Binary compatibility validated against the published baseline | ✅ | `EnablePackageValidation` vs. 1.1.0 | +| Public API surface locked against accidental breaks | ✅ | PublicApiAnalyzers, `PublicAPI.Shipped.txt` in all seven library packages | +| Binary compatibility validated against the published baseline | ⚠️ | `EnablePackageValidation` (baseline `1.7.1`) via `Directory.Build.props`; overridden **off** in the Aws, AzureKeyVault, Gcp, and Analyzers packages — a pre-first-publish gate never flipped after they shipped | | Key material zeroed (`CryptographicOperations.ZeroMemory` in `finally`) | ✅ | Throughout `Internal/`; reviewed per change | | AOT/trim compatible, proven end-to-end | ✅ | `IsAotCompatible` + CI native-AOT publish & round-trip smoke test | | Async + cancellation honored on all I/O | ✅ | Public API contract; cancellation cleanup tests | diff --git a/docs/KEY-FILE-FORMAT.md b/docs/KEY-FILE-FORMAT.md index c520085..7a061f1 100644 --- a/docs/KEY-FILE-FORMAT.md +++ b/docs/KEY-FILE-FORMAT.md @@ -18,7 +18,7 @@ therefore mostly a *framing* specification. | -----: | ---: | ------------- | -------------------------------------------------------- | | 0 | 4 | Magic | `PQKF` (`0x50 0x51 0x4B 0x46`) | | 4 | 1 | FormatVersion | `1` | -| 5 | … | Body | A `.pqfe` v2 passphrase container (KeySource 1 or 2) | +| 5 | … | Body | A `.pqfe` v2 passphrase container (KeySource 1) | The container's **plaintext** — the bytes recovered after successful authenticated decryption — is: @@ -44,8 +44,9 @@ authentication. 1. Writers MUST emit the magic and version exactly as specified, followed immediately by the container — no padding, no trailing bytes. -2. The container MUST be a passphrase container (KeySource `1` = PBKDF2-HMAC-SHA256 or - `2` = Argon2id). Recipient and key-provider KeySources are not permitted in a key file. +2. The container MUST be a passphrase container (KeySource `1`), whose KeyParams carry + KdfId `1` = PBKDF2-HMAC-SHA256 or `2` = Argon2id. Recipient and key-provider KeySources + are not permitted in a key file. 3. The reference implementation defaults the KDF to **Argon2id** (19 MiB memory, 2 passes) — key files are small and long-lived, so the KDF is the entire cost of opening one, which is exactly the workload memory-hard KDFs exist for. Writers MAY choose PBKDF2 or different diff --git a/docs/KEY-MANAGEMENT.md b/docs/KEY-MANAGEMENT.md index 14a9436..32c8496 100644 --- a/docs/KEY-MANAGEMENT.md +++ b/docs/KEY-MANAGEMENT.md @@ -25,6 +25,13 @@ public interface IContentKeyProvider } ``` +> **Contract: the content key MUST be fresh on every call.** Cross-file AES-GCM nonce uniqueness +> rests entirely on per-file key freshness — the on-disk nonce prefix is only 4 random bytes. A +> provider that caches or reuses a data key (a tempting KMS cost optimization) collapses that to a +> 32-bit birthday bound across files (~50% collision odds by ~77k files), and colliding files +> reuse (key, nonce) pairs: keystream XOR leaks plaintext and the GCM authentication key becomes +> recoverable. Never cache or reuse the plaintext CEK. + Usage (any `PqFileEncryptor` / `PqFileDecryptor` overload accepts a provider): ```csharp @@ -60,33 +67,45 @@ var gcp = new GcpKmsContentKeyProvider(await KeyManagementServiceClient.CreateAs ## Multiple recipients / access groups -Designed as `KeySource = 4` in [ROADMAP-v3.md](ROADMAP-v3.md): one CEK wrapped to N recipients (or +Shipped as `KeySource = 4` in the Hybrid package (design history in [ROADMAP-v3.md](ROADMAP-v3.md)): one CEK wrapped to N recipients (or N KMS key IDs), so any authorized party can open the file. Decryption tries each wrap block until one succeeds, failing closed with no oracle about which recipients are present. -## Rotation & revocation without re-encrypting the file +## Rotation & revocation -Because the data is encrypted under the CEK and only the **CEK** is wrapped, you can rotate access -credentials by **re-wrapping the CEK** — no need to re-encrypt multi-gigabyte payloads: +Rotating credentials on a **format v2** container means **re-encrypting the file as a streaming +transcode** — not a header-only rewrite. That is a consequence of a deliberate v2 design property: +the entire serialized header, including the wrapped CEK, is bound as AAD into **every** content +frame. Replacing the wrap changes every frame's AAD, so every frame must be re-authenticated — and +re-tagging under the *same* CEK and nonces with new AAD would emit a second AES-GCM tag for each +(key, nonce) pair. Old and rotated copies of a file legitimately coexist, and an attacker holding +both could use such tag pairs to recover the GCM authentication key and forge ciphertext. A safe +rotation therefore looks like: ``` -rewrap: CEK = unwrap(old credential) # decrypt just the small wrapped key - wrapped = wrap(new credential / new recipient set) - rewrite only the container header # body (the bulk) is untouched +rotate: CEK_old = unwrap(old credential) # small + CEK_new = fresh random 32 bytes # never reuse the old CEK + stream: decrypt chunk under CEK_old → re-encrypt under CEK_new + (fresh nonce prefix; new header wrapping CEK_new) + atomically replace the file # plaintext never persisted ``` -- **Revoke** a recipient by re-wrapping to the new recipient set (excluding the revoked one) and - replacing the header. Already-distributed copies cannot be retroactively un-shared — rotation - protects future access, not past disclosure. -- A `rewrap` tool/API is planned alongside multi-recipient support; the header-only rewrite is - cheap and the body's authentication is unaffected (the header is bound as chunk AAD, so a rewrap - re-derives chunk AAD — meaning rewrap re-authenticates, not just edits bytes; the design account - for this is part of the implementation work). +- Memory stays bounded (one chunk at a time) and plaintext never touches disk, but the **whole + file is read and rewritten** — budget rotation as full-file I/O, not a 32-byte edit. +- A detached `.sig` sidecar signs the old bytes and becomes stale after rotation; re-sign the + rotated file or remove the sidecar explicitly. +- **Revoke** a recipient by rotating to a new recipient set that excludes them. + Already-distributed copies cannot be retroactively un-shared — rotation protects future access, + not past disclosure. With cloud KMS, rotating the *master* key inside the KMS often needs no + file rewrite at all: providers keep old key versions decryptable, so existing wraps still open. +- Rotation tooling is design-only today ([KNOWN-GAPS.md](../KNOWN-GAPS.md)). True header-only + rewrap requires a format that keeps wrap material *outside* the chunk-AAD commitment — a + format-v3 candidate, not a v2 patch. ## Status The provider seam, the local-KEK provider, the **AWS KMS provider**, the **Azure Key Vault -provider**, and hybrid multi-recipient encryption (`KeySource = 4`, in the Hybrid package) +provider**, the **Google Cloud KMS provider**, and hybrid multi-recipient encryption (`KeySource = 4`, in the Hybrid package) are **shipped**. Rewrap/rotation tooling and Vault/PKCS#11 providers remain design-only, tracked in [KNOWN-GAPS.md](../KNOWN-GAPS.md). diff --git a/docs/REPRODUCIBLE-BUILDS.md b/docs/REPRODUCIBLE-BUILDS.md index 189a138..f4bd5d8 100644 --- a/docs/REPRODUCIBLE-BUILDS.md +++ b/docs/REPRODUCIBLE-BUILDS.md @@ -72,11 +72,11 @@ Verifying one release end-to-end: # 1. Pull a published version from nuget.org. NUPKG_DIR=$(mktemp -d) dotnet nuget locals temp -c >/dev/null -curl -L "https://www.nuget.org/api/v2/package/PostQuantum.FileEncryption/1.0.1" \ +curl -L "https://www.nuget.org/api/v2/package/PostQuantum.FileEncryption/1.7.1" \ -o "$NUPKG_DIR/published.nupkg" # 2. Rebuild from source at the same tag. -git clone --branch v1.0.1 --depth 1 \ +git clone --branch v1.7.1 --depth 1 \ https://github.com/systemslibrarian/postquantum-file-encryption /tmp/pqfe-repro cd /tmp/pqfe-repro CI=true dotnet pack src/PostQuantum.FileEncryption/PostQuantum.FileEncryption.csproj \ @@ -85,7 +85,7 @@ CI=true dotnet pack src/PostQuantum.FileEncryption/PostQuantum.FileEncryption.cs # 3. Compare the .dll, .pdb, and .xml inside both .nupkgs. unzip -p "$NUPKG_DIR/published.nupkg" 'lib/net10.0/PostQuantum.FileEncryption.dll' \ | sha256sum -unzip -p "$NUPKG_DIR/local/PostQuantum.FileEncryption.1.0.1.nupkg" \ +unzip -p "$NUPKG_DIR/local/PostQuantum.FileEncryption.1.7.1.nupkg" \ 'lib/net10.0/PostQuantum.FileEncryption.dll' | sha256sum # The two sums must match. Repeat for .pdb and .xml. Repeat for the Hybrid package. @@ -99,8 +99,8 @@ To compare the entire `.nupkg` byte-for-byte (modulo the nuget.org repo signatur verification script that the CI job runs: ```bash -.github/scripts/verify-reproducibility.sh v1.0.1 PostQuantum.FileEncryption -.github/scripts/verify-reproducibility.sh v1.0.1 PostQuantum.FileEncryption.Hybrid +.github/scripts/verify-reproducibility.sh v1.7.1 PostQuantum.FileEncryption +.github/scripts/verify-reproducibility.sh v1.7.1 PostQuantum.FileEncryption.Hybrid ``` The script also accepts `PostQuantum.FileEncryption.Extensions.DependencyInjection` and diff --git a/docs/ROADMAP-2.0.md b/docs/ROADMAP-2.0.md index b75a96e..1616812 100644 --- a/docs/ROADMAP-2.0.md +++ b/docs/ROADMAP-2.0.md @@ -11,7 +11,7 @@ There are two version tracks, and conflating them causes confusion: | Track | Today | At the next major | | --- | --- | --- | -| **NuGet package version** | `1.6.0` | `2.0.0` | +| **NuGet package version** | `1.7.1` | `2.0.0` | | **`.pqfe` container `FormatVersion`** (byte 5 of every file) | `2` | `3` | Format **v2 is what every `1.x`-encrypted file on disk carries today** — it is very much "out diff --git a/docs/SUPPLY-CHAIN.md b/docs/SUPPLY-CHAIN.md index 583eba8..d8a2ae4 100644 --- a/docs/SUPPLY-CHAIN.md +++ b/docs/SUPPLY-CHAIN.md @@ -15,12 +15,9 @@ produces and attaches: | Artifact | What it is | Where to find it | | --- | --- | --- | -| `PostQuantum.FileEncryption.X.Y.Z.nupkg` | Core library package | nuget.org and GitHub Release | -| `PostQuantum.FileEncryption.X.Y.Z.snupkg` | Core symbols package | nuget.org and GitHub Release | -| `PostQuantum.FileEncryption.Hybrid.X.Y.Z.nupkg` | Hybrid package | nuget.org and GitHub Release | -| `PostQuantum.FileEncryption.Hybrid.X.Y.Z.snupkg` | Hybrid symbols package | nuget.org and GitHub Release | -| `sbom.core.cdx.json` | CycloneDX SBOM for the core | GitHub Release | -| `sbom.hybrid.cdx.json` | CycloneDX SBOM for the Hybrid package | GitHub Release | +| `PostQuantum.FileEncryption[.].X.Y.Z.nupkg` | The nine lockstep packages: core, Hybrid, Signing, Aws, AzureKeyVault, Gcp, DI extensions, Analyzers, and the `pqfe` tool | nuget.org and GitHub Release | +| `PostQuantum.FileEncryption[.].X.Y.Z.snupkg` | Symbol packages for the seven library packages | nuget.org and GitHub Release | +| `sbom..cdx.json` | CycloneDX SBOMs: core, hybrid, signing, aws, azurekeyvault, gcp, di, and tool | GitHub Release | | Build-provenance attestation | SLSA-style attestation over every `.nupkg` | GitHub attestations API | The release workflow also runs `Meziantou.Framework.NuGetPackageValidation.Tool` against @@ -36,10 +33,11 @@ owner and the GitHub attestation infrastructure. ```bash # Download the package you want to verify, then: -gh attestation verify PostQuantum.FileEncryption.1.0.1.nupkg \ +gh attestation verify PostQuantum.FileEncryption.1.7.1.nupkg \ --owner systemslibrarian -# The same command works for the Hybrid package and the .snupkg symbol files. +# The same command works for every published .nupkg. Symbol packages (.snupkg) are NOT +# covered by the attestation today — verifying one fails by construction. ``` A successful run prints the subject digest, the workflow that produced it, the commit SHA, @@ -53,7 +51,7 @@ its version, and its hash. ```bash # Pull from the release: -gh release download v1.0.1 -p 'sbom.*.cdx.json' +gh release download v1.7.1 -p 'sbom.*.cdx.json' # Glance at the core's direct + transitive components: jq '.components | map({name, version})' sbom.core.cdx.json @@ -93,10 +91,14 @@ the verification script, and the CI job that runs it on every release tag are in [REPRODUCIBLE-BUILDS.md](REPRODUCIBLE-BUILDS.md): ```bash -.github/scripts/verify-reproducibility.sh v1.0.1 PostQuantum.FileEncryption -.github/scripts/verify-reproducibility.sh v1.0.1 PostQuantum.FileEncryption.Hybrid +.github/scripts/verify-reproducibility.sh v1.7.1 PostQuantum.FileEncryption +.github/scripts/verify-reproducibility.sh v1.7.1 PostQuantum.FileEncryption.Hybrid ``` +The scripted verification currently covers the core, Hybrid, DI extensions, and tool +packages. The other five build from the same deterministic `Directory.Build.props` +settings but are not yet in the script's matrix. + A failing run is a finding worth a private security report — see [SECURITY.md](../SECURITY.md). @@ -112,7 +114,7 @@ dotnet nuget locals all --list # find your global package cache strings -n 8 lib/net10.0/PostQuantum.FileEncryption.dll | grep -i sourcelink # or use the package-validation tool directly: dotnet tool install --global Meziantou.Framework.NuGetPackageValidation.Tool -meziantou.validate-nuget-package PostQuantum.FileEncryption.1.0.1.nupkg --excluded-rules Symbols +meziantou.validate-nuget-package PostQuantum.FileEncryption.1.7.1.nupkg --excluded-rules Symbols ``` A green result means the package is deterministic, SourceLink-wired, includes the README, @@ -136,7 +138,8 @@ previous release, so every pack additionally proves binary compatibility at buil ## Continuous assurance -These checks run automatically on every push (not just on release tags): +These checks run continuously — on every push, and some on a nightly/weekly schedule (not +just on release tags): | Job | Workflow | What it catches | | --- | --- | --- | @@ -164,7 +167,7 @@ public Scorecard dashboard (linked from the README badge) shows the score histor # - dotnet (test run + package validation) # 1. Pull artifacts: -gh release download v1.0.1 +gh release download v1.7.1 # 2. Verify build provenance on every .nupkg: for nupkg in PostQuantum.FileEncryption*.nupkg; do diff --git a/samples/Pqfe.Cli/Pqfe.Cli.csproj b/samples/Pqfe.Cli/Pqfe.Cli.csproj index e1946fa..b3dcca0 100644 --- a/samples/Pqfe.Cli/Pqfe.Cli.csproj +++ b/samples/Pqfe.Cli/Pqfe.Cli.csproj @@ -29,7 +29,7 @@ 6.0.0 pqfe — PostQuantum.FileEncryption CLI Command-line file encryption and signing built on PostQuantum.FileEncryption: `pqfe encrypt` / `pqfe decrypt` for the FROZEN .pqfe v2 container format, plus `pqfe keygen` / `pqfe sign` / `pqfe verify` for detached Ed25519 + ML-DSA-65 hybrid signatures (.sig sidecars). Authenticated AES-256-GCM with PBKDF2-HMAC-SHA256 or Argon2id passphrase derivation, atomic output files, fail-closed decryption and verification (wrong passphrase, tampered ciphertext, and bad signatures are indistinguishable within their class), and sysexits.h exit codes for scripting. Passphrases come from a no-echo prompt or an environment variable for CI use. Install: dotnet tool install -g PostQuantum.FileEncryption.Tool. Requires the .NET 10 runtime or later. - 1.5.0 — keygen gains --encrypt, writing the private key as a passphrase-protected PQKF key file (an Argon2id-hardened .pqfe container); sign detects an encrypted key file automatically. Hardening: Ctrl+C is cooperative cancellation (exit 130, and it cancels the passphrase prompt itself), keygen writes the private key 0600 on Unix and cleans up a half-written pair, passphrase-prompt failures unwind without skipping key zeroing or cleanup, and --passphrase-env is rejected where it would be silently ignored. No change to the .pqfe v2 container format, which remains FROZEN for the 1.x line. See CHANGELOG.md. + Lockstep release of the PostQuantum.FileEncryption family — see CHANGELOG.md for the current version’s changes. Highlights from recent releases: keygen gains --encrypt, writing the private key as a passphrase-protected PQKF key file (an Argon2id-hardened .pqfe container); sign detects an encrypted key file automatically. Hardening: Ctrl+C is cooperative cancellation (exit 130, and it cancels the passphrase prompt itself), keygen writes the private key 0600 on Unix and cleans up a half-written pair, passphrase-prompt failures unwind without skipping key zeroing or cleanup, and --passphrase-env is rejected where it would be silently ignored. No change to the .pqfe v2 container format, which remains FROZEN for the 1.x line. See CHANGELOG.md. encryption;file-encryption;signing;signature;cli;dotnet-tool;aes-gcm;aes-256;argon2id;pbkdf2;passphrase;authenticated-encryption;aead;ml-dsa;ed25519;cryptography;security;post-quantum;pqc;dotnet;net10 MIT README.md diff --git a/scripts/check-docs-consistency.sh b/scripts/check-docs-consistency.sh index 1e6cfca..fe51af8 100644 --- a/scripts/check-docs-consistency.sh +++ b/scripts/check-docs-consistency.sh @@ -3,31 +3,46 @@ # # The release workflow already fails a *tag* if the README status line or an install snippet # lags the version being published. That check only runs at release time, so drift can still -# accumulate on main between releases — which is exactly how the "Last reviewed against 1.2.0", -# "1.5.0", and "Now — 1.0.1" markers went stale while the code shipped 1.6.0. This script runs -# on every push and pull request (see .github/workflows/docs-consistency.yml) and enforces: +# accumulate on main between releases — which is exactly how the SECURITY.md supported-versions +# cell, the "Last reviewed against" markers, and the supply-chain artifact examples went stale +# across the 1.6.0–1.7.1 releases. This script runs on every push and pull request (see +# .github/workflows/docs-consistency.yml) and enforces: # # 1. Current-version markers match the core package : # * the root README **Status:** line, # * the ROADMAP "## Now — `X`" heading, -# * every "Last reviewed against: **`X`**" marker in any tracked Markdown file. -# 2. Every relative Markdown link resolves to a file that exists — no dead internal links. +# * every "Last reviewed against: **`X`**" marker in any tracked Markdown file, +# * the SECURITY.md supported-versions cell ("current: `X`"), +# * the ROADMAP-2.0 "NuGet package version → Today" cell, +# * the AUDIT-SCOPE pinned release tag. +# 2. Worked artifact examples name the current version (outside CHANGELOG.md): +# * `PostQuantum.FileEncryption[.].X.Y.Z.nupkg` artifact names, +# * `gh release download vX.Y.Z`, +# * `verify-reproducibility.sh vX.Y.Z`, +# * `--branch vX.Y.Z` clone examples and nuget.org `/package//X.Y.Z` URLs, +# * `dotnet add package … --version X.Y.Z` install snippets in every Markdown file. +# 3. Every relative Markdown link resolves to a file that exists — no dead internal links. # # Historical version mentions (CHANGELOG entries, "shipped 1.3.0" prose, the compare-link # footer) are deliberately NOT matched: they are facts about earlier releases, not the current # version. See CLAUDE.md, "When you bump the package version". # +# Portability: uses only POSIX grep -E / sed / perl (for link extraction) — no GNU-only +# `grep -P` — so it runs on macOS (BSD userland) as well as Linux CI. +# # Run locally: bash scripts/check-docs-consistency.sh set -euo pipefail cd "$(git rev-parse --show-toplevel)" core_csproj="src/PostQuantum.FileEncryption/PostQuantum.FileEncryption.csproj" -version="$(grep -oP '(?<=)[^<]+' "$core_csproj" | head -n1)" +version="$(sed -n 's/.*\([^<]*\)<\/Version>.*/\1/p' "$core_csproj" | head -n1)" if [ -z "${version:-}" ]; then echo "::error file=$core_csproj::could not read " >&2 exit 1 fi +# Regex-escaped version for use inside grep -E patterns. +ver_re="$(printf '%s' "$version" | sed 's/\./\\./g')" echo "Canonical package version: $version" # fail flag lives in a file so it survives the subshells created by `... | while`. @@ -37,32 +52,71 @@ mark_fail() { printf 'x' >> "$fail_marker"; } # ------------------------------------------------------------------ 1. version markers -# Explicit single-line markers that must name the current version. -if ! grep -qP "Status:\s*\`${version}\`" README.md; then +if ! grep -Eq "Status:[[:space:]]*\`${ver_re}\`" README.md; then echo "::error file=README.md::the **Status:** line does not name the current version \`${version}\`" >&2 mark_fail fi -if ! grep -qP "## Now — \`${version}\`" ROADMAP.md; then +if ! grep -Eq "^## Now — \`${ver_re}\`" ROADMAP.md; then echo "::error file=ROADMAP.md::the \"## Now —\" heading does not name the current version \`${version}\`" >&2 mark_fail fi +if ! grep -Eq "current: \`${ver_re}\`" SECURITY.md; then + echo "::error file=SECURITY.md::the supported-versions cell does not say current: \`${version}\`" >&2 + mark_fail +fi +if ! grep -Eq "\*\*NuGet package version\*\* \| \`${ver_re}\`" docs/ROADMAP-2.0.md; then + echo "::error file=docs/ROADMAP-2.0.md::the \"NuGet package version → Today\" cell does not say \`${version}\`" >&2 + mark_fail +fi +if ! grep -Eq "latest release tag — \*\*\`v${ver_re}\`\*\*" docs/AUDIT-SCOPE.md; then + echo "::error file=docs/AUDIT-SCOPE.md::the pinned audit revision does not name \`v${version}\`" >&2 + mark_fail +fi # Generic: every "Last reviewed against: **`X`**" marker in any Markdown file must equal it. while IFS= read -r hit; do [ -z "$hit" ] && continue f="${hit%%:*}" - found="$(printf '%s' "$hit" | grep -oP 'Last reviewed against: \*\*`\K[^`]+')" + found="$(printf '%s\n' "$hit" | sed -n 's/.*Last reviewed against: \*\*`\([^`]*\)`.*/\1/p')" if [ "$found" != "$version" ]; then echo "::error file=$f::stale marker 'Last reviewed against \`$found\`'; the current version is \`$version\`" >&2 mark_fail fi -done < <(git grep -nP 'Last reviewed against: \*\*`[^`]+`\*\*' -- '*.md' || true) +done < <(git grep -nE 'Last reviewed against: \*\*`[^`]+`\*\*' -- '*.md' || true) + +# ------------------------------------------------------------------ 2. artifact examples + +# Each entry: +check_examples() { + local find_re="$1" want_re="$2" what="$3" + while IFS= read -r hit; do + [ -z "$hit" ] && continue + local f="${hit%%:*}" rest="${hit#*:}" line="${hit#*:}" + line="${rest%%:*}" + if ! printf '%s\n' "$hit" | grep -Eq "$want_re"; then + echo "::error file=$f,line=$line::stale $what (expected version \`$version\`): ${hit#*:*:}" >&2 + mark_fail + fi + done < <(git grep -nE -e "$find_re" -- '*.md' ':!CHANGELOG.md' || true) +} + +check_examples 'PostQuantum\.FileEncryption[A-Za-z.]*\.[0-9]+\.[0-9]+\.[0-9]+\.s?nupkg' \ + "\.${ver_re}\.s?nupkg" 'package artifact name' +check_examples 'gh release download v[0-9]+\.[0-9]+\.[0-9]+' \ + "gh release download v${ver_re}" 'release download example' +check_examples 'verify-reproducibility\.sh v[0-9]+\.[0-9]+\.[0-9]+' \ + "verify-reproducibility\.sh v${ver_re}" 'reproducibility example' +check_examples '--branch v[0-9]+\.[0-9]+\.[0-9]+' \ + "[-][-]branch v${ver_re}" 'clone-at-tag example' +check_examples '/package/PostQuantum\.FileEncryption/[0-9]+\.[0-9]+\.[0-9]+' \ + "/package/PostQuantum\.FileEncryption/${ver_re}" 'nuget.org package URL' +check_examples '--version [0-9]+\.[0-9]+\.[0-9]+' \ + "[-][-]version ${ver_re}" 'install snippet' -# ------------------------------------------------------------------ 2. dead internal links +# ------------------------------------------------------------------ 3. dead internal links while IFS= read -r mdfile; do dir="$(dirname "$mdfile")" - # Inline-link targets: the (...) in ](...). `-n` gives the line, `\K` drops the ]( prefix. while IFS= read -r m; do [ -z "$m" ] && continue lineno="${m%%:*}" @@ -77,7 +131,7 @@ while IFS= read -r mdfile; do echo "::error file=$mdfile,line=$lineno::dead internal link '$target' (no file at '$dir/$path')" >&2 mark_fail fi - done < <(grep -noP '\]\(\K[^)]+' "$mdfile" 2>/dev/null || true) + done < <(perl -ne 'while (/\]\(([^)]+)\)/g) { print "$.:$1\n" }' "$mdfile" 2>/dev/null || true) done < <(git ls-files '*.md') # ------------------------------------------------------------------ verdict diff --git a/src/PostQuantum.FileEncryption.Analyzers/PostQuantum.FileEncryption.Analyzers.csproj b/src/PostQuantum.FileEncryption.Analyzers/PostQuantum.FileEncryption.Analyzers.csproj index 67a8cee..82ee4ce 100644 --- a/src/PostQuantum.FileEncryption.Analyzers/PostQuantum.FileEncryption.Analyzers.csproj +++ b/src/PostQuantum.FileEncryption.Analyzers/PostQuantum.FileEncryption.Analyzers.csproj @@ -24,7 +24,7 @@ 6.0.0 PostQuantum.FileEncryption.Analyzers Roslyn analyzers that catch dangerous misuse of the PostQuantum.FileEncryption family at compile time, before it ships: hard-coded passphrases (PQFE101), raw private-key bytes written to disk instead of a passphrase-protected key file (PQFE102), discarded encrypt/decrypt/sign/verify tasks whose authentication never completes or fails unobserved (PQFE103), and silently swallowed fail-closed exceptions (PQFE104). Real-world encryption failures come from misuse, not broken primitives — these rules put the library's fail-closed discipline into the IDE. Development-only dependency; adds nothing to your runtime output. - 1.5.0 — first release of the analyzer package, versioned in lockstep with the PostQuantum.FileEncryption family. Rules PQFE101–PQFE104: hard-coded passphrases, raw private-key bytes written to disk, discarded encrypt/decrypt/sign/verify tasks, and silently swallowed fail-closed exceptions. See the package README and docs/ANTI-PATTERNS.md for the reasoning behind each rule. + Lockstep release of the PostQuantum.FileEncryption family — see CHANGELOG.md for the current version’s changes. Highlights from recent releases: first release of the analyzer package, versioned in lockstep with the PostQuantum.FileEncryption family. Rules PQFE101–PQFE104: hard-coded passphrases, raw private-key bytes written to disk, discarded encrypt/decrypt/sign/verify tasks, and silently swallowed fail-closed exceptions. See the package README and docs/ANTI-PATTERNS.md for the reasoning behind each rule. analyzers;roslyn;static-analysis;security;misuse-resistance;encryption;file-encryption;cryptography;post-quantum;pqc;dotnet;net8;net10 MIT README.md diff --git a/src/PostQuantum.FileEncryption.Aws/PostQuantum.FileEncryption.Aws.csproj b/src/PostQuantum.FileEncryption.Aws/PostQuantum.FileEncryption.Aws.csproj index d7cde9b..ecbf35d 100644 --- a/src/PostQuantum.FileEncryption.Aws/PostQuantum.FileEncryption.Aws.csproj +++ b/src/PostQuantum.FileEncryption.Aws/PostQuantum.FileEncryption.Aws.csproj @@ -16,7 +16,7 @@ 6.0.0 PostQuantum.FileEncryption.Aws AWS KMS envelope-key provider for PostQuantum.FileEncryption, for .NET 8 and .NET 10. AwsKmsContentKeyProvider implements the IContentKeyProvider seam over AWS KMS GenerateDataKey/Decrypt: every file is encrypted under a fresh per-file content key that KMS wraps under your customer master key — the master key never leaves AWS. The wrap is bound to the configured key id and a library-specific encryption context, and unwrap fails closed (PqDecryptionException, no oracle) on any invalid or foreign ciphertext. Works with every PqFileEncryptor/PqFileDecryptor overload that accepts a key provider; rotation re-wraps the small content key instead of re-encrypting the file. Public API surface locked by Microsoft.CodeAnalysis.PublicApiAnalyzers; CycloneDX SBOM and SLSA-style build-provenance attestation on every release. - 1.5.0 — hardening in lockstep with the family: unwrap rejects oversized wrapped blobs client-side (the KMS Decrypt limit is 6,144 bytes), so a hostile container yields the documented fail-closed PqDecryptionException instead of a raw SDK exception after a doomed network round-trip, and the engine now rejects any provider-returned content key that is not exactly 32 bytes. No change to the provider's API or the .pqfe v2 container format, which remains FROZEN for the 1.x line. See CHANGELOG.md. + Lockstep release of the PostQuantum.FileEncryption family — see CHANGELOG.md for the current version’s changes. Highlights from recent releases: hardening in lockstep with the family: unwrap rejects oversized wrapped blobs client-side (the KMS Decrypt limit is 6,144 bytes), so a hostile container yields the documented fail-closed PqDecryptionException instead of a raw SDK exception after a doomed network round-trip, and the engine now rejects any provider-returned content key that is not exactly 32 bytes. No change to the provider's API or the .pqfe v2 container format, which remains FROZEN for the 1.x line. See CHANGELOG.md. aws;kms;aws-kms;envelope-encryption;key-management;data-key;encryption;file-encryption;aes-gcm;authenticated-encryption;aead;cryptography;security;post-quantum;pqc;dotnet;net8;net10 MIT README.md diff --git a/src/PostQuantum.FileEncryption.AzureKeyVault/PostQuantum.FileEncryption.AzureKeyVault.csproj b/src/PostQuantum.FileEncryption.AzureKeyVault/PostQuantum.FileEncryption.AzureKeyVault.csproj index da5bbb6..5a304fb 100644 --- a/src/PostQuantum.FileEncryption.AzureKeyVault/PostQuantum.FileEncryption.AzureKeyVault.csproj +++ b/src/PostQuantum.FileEncryption.AzureKeyVault/PostQuantum.FileEncryption.AzureKeyVault.csproj @@ -16,7 +16,7 @@ 6.0.0 PostQuantum.FileEncryption.AzureKeyVault Azure Key Vault / Managed HSM envelope-key provider for PostQuantum.FileEncryption, for .NET 8 and .NET 10. AzureKeyVaultContentKeyProvider implements the IContentKeyProvider seam over Key Vault wrap/unwrap (RSA-OAEP-256 by default): every file is encrypted under a fresh per-file content key wrapped by your Key Vault key — the key-encryption key never leaves the vault or HSM. Unwrap is pinned to the configured key id and algorithm and fails closed (PqDecryptionException, no oracle) on any invalid or foreign wrapped key. Works with every PqFileEncryptor/PqFileDecryptor overload that accepts a key provider; rotation re-wraps the small content key instead of re-encrypting the file. Public API surface locked by Microsoft.CodeAnalysis.PublicApiAnalyzers; CycloneDX SBOM and SLSA-style build-provenance attestation on every release. - 1.5.0 — hardening in lockstep with the family: a locally-operating CryptographyClient's CryptographicException now maps to the same fail-closed PqDecryptionException as the remote path (one message, no oracle), and the engine now rejects any provider-returned content key that is not exactly 32 bytes. No change to the provider's API or the .pqfe v2 container format, which remains FROZEN for the 1.x line. See CHANGELOG.md. + Lockstep release of the PostQuantum.FileEncryption family — see CHANGELOG.md for the current version’s changes. Highlights from recent releases: hardening in lockstep with the family: a locally-operating CryptographyClient's CryptographicException now maps to the same fail-closed PqDecryptionException as the remote path (one message, no oracle), and the engine now rejects any provider-returned content key that is not exactly 32 bytes. No change to the provider's API or the .pqfe v2 container format, which remains FROZEN for the 1.x line. See CHANGELOG.md. azure;key-vault;azure-key-vault;managed-hsm;envelope-encryption;key-management;key-wrap;encryption;file-encryption;aes-gcm;authenticated-encryption;aead;cryptography;security;post-quantum;pqc;dotnet;net8;net10 MIT README.md diff --git a/src/PostQuantum.FileEncryption.Extensions.DependencyInjection/PostQuantum.FileEncryption.Extensions.DependencyInjection.csproj b/src/PostQuantum.FileEncryption.Extensions.DependencyInjection/PostQuantum.FileEncryption.Extensions.DependencyInjection.csproj index cb092d0..ede7dad 100644 --- a/src/PostQuantum.FileEncryption.Extensions.DependencyInjection/PostQuantum.FileEncryption.Extensions.DependencyInjection.csproj +++ b/src/PostQuantum.FileEncryption.Extensions.DependencyInjection/PostQuantum.FileEncryption.Extensions.DependencyInjection.csproj @@ -16,7 +16,7 @@ 6.0.0 PostQuantum.FileEncryption.Extensions.DependencyInjection Microsoft.Extensions.DependencyInjection integration for PostQuantum.FileEncryption, for .NET 8 and .NET 10. Adds AddPqFileEncryption(), AddPqHybridFileEncryption(), and AddPqSigning() extension methods that register PqFileEncryptor/PqFileDecryptor, PqHybridEncryptor/PqHybridDecryptor, and PqSigner/PqVerifier as singletons, with optional PqEncryptionOptions. Brings the core library (constant-memory streaming AES-256-GCM over the FROZEN .pqfe v2 container, PBKDF2-HMAC-SHA256 or Argon2id), the production X25519 + ML-KEM-768 hybrid package, and detached Ed25519 + ML-DSA-65 signatures into any host using the standard .NET service container — ASP.NET Core, Worker Services, console hosts. Public API surface locked by Microsoft.CodeAnalysis.PublicApiAnalyzers; CycloneDX SBOM and SLSA-style build-provenance attestation on every release. - 1.5.0 — AddPqFileEncryption and AddPqHybridFileEncryption gain overloads taking a PqDecryptionLimits, so hosts that decrypt containers from untrusted sources can register capped decryptors in one line. Tracks the family in lockstep; no change to the .pqfe v2 container format, which remains FROZEN for the 1.x line. See CHANGELOG.md. + Lockstep release of the PostQuantum.FileEncryption family — see CHANGELOG.md for the current version’s changes. Highlights from recent releases: AddPqFileEncryption and AddPqHybridFileEncryption gain overloads taking a PqDecryptionLimits, so hosts that decrypt containers from untrusted sources can register capped decryptors in one line. Tracks the family in lockstep; no change to the .pqfe v2 container format, which remains FROZEN for the 1.x line. See CHANGELOG.md. dependency-injection;di;servicecollection;microsoft-extensions;post-quantum;pqc;ml-kem;x25519;hybrid;encryption;file-encryption;aes-gcm;authenticated-encryption;aead;cryptography;security;dotnet;net8;net10 MIT README.md diff --git a/src/PostQuantum.FileEncryption.Gcp/PostQuantum.FileEncryption.Gcp.csproj b/src/PostQuantum.FileEncryption.Gcp/PostQuantum.FileEncryption.Gcp.csproj index eec73db..b57362c 100644 --- a/src/PostQuantum.FileEncryption.Gcp/PostQuantum.FileEncryption.Gcp.csproj +++ b/src/PostQuantum.FileEncryption.Gcp/PostQuantum.FileEncryption.Gcp.csproj @@ -16,7 +16,7 @@ 6.0.0 PostQuantum.FileEncryption.Gcp Google Cloud KMS envelope-key provider for PostQuantum.FileEncryption, for .NET 8 and .NET 10. GcpKmsContentKeyProvider implements the IContentKeyProvider seam over Cloud KMS Encrypt/Decrypt: every file is encrypted under a fresh per-file content key that Cloud KMS wraps under your key-ring key — the master key never leaves Google Cloud. The wrap is bound to the configured CryptoKey and library-specific additional authenticated data, every request and response is verified end to end with the CRC32C integrity fields Cloud KMS provides, and unwrap fails closed (PqDecryptionException, no oracle) on any invalid or foreign ciphertext. Works with every PqFileEncryptor/PqFileDecryptor overload that accepts a key provider; rotation re-wraps the small content key instead of re-encrypting the file. Public API surface locked by Microsoft.CodeAnalysis.PublicApiAnalyzers; CycloneDX SBOM and SLSA-style build-provenance attestation on every release. - Initial release of the Google Cloud KMS envelope-key provider, completing the AWS KMS / Azure Key Vault / Google Cloud KMS trio over the IContentKeyProvider seam. No change to the .pqfe v2 container format, which remains FROZEN for the 1.x line. See CHANGELOG.md. + Lockstep release of the PostQuantum.FileEncryption family — see CHANGELOG.md for the current version’s changes. Highlights from recent releases: the Google Cloud KMS envelope-key provider, completing the AWS KMS / Azure Key Vault / Google Cloud KMS trio over the IContentKeyProvider seam. No change to the .pqfe v2 container format, which remains FROZEN for the 1.x line. See CHANGELOG.md. gcp;google-cloud;kms;cloud-kms;gcp-kms;envelope-encryption;key-management;data-key;encryption;file-encryption;aes-gcm;authenticated-encryption;aead;cryptography;security;post-quantum;pqc;dotnet;net8;net10 MIT README.md diff --git a/src/PostQuantum.FileEncryption.Hybrid/PostQuantum.FileEncryption.Hybrid.csproj b/src/PostQuantum.FileEncryption.Hybrid/PostQuantum.FileEncryption.Hybrid.csproj index cba1a4c..c4c308a 100644 --- a/src/PostQuantum.FileEncryption.Hybrid/PostQuantum.FileEncryption.Hybrid.csproj +++ b/src/PostQuantum.FileEncryption.Hybrid/PostQuantum.FileEncryption.Hybrid.csproj @@ -16,7 +16,7 @@ 6.0.0 PostQuantum.FileEncryption.Hybrid Open-source (MIT) post-quantum hybrid public-key encryption for PostQuantum.FileEncryption, for .NET 8 and .NET 10. Adds X25519 + ML-KEM-768 (FIPS 203) hybrid recipient encryption and multi-recipient support on top of the FROZEN, publicly specified .pqfe v2 container format — a file's content key stays safe if either primitive is later broken. Fully managed via BouncyCastle for both primitives (no platform ML-KEM dependency); runs anywhere .NET 8 or later does, including Linux, Windows, macOS, and constrained environments. Constant-memory streaming AES-256-GCM data plane via the core package handles files of any size. Public API surface locked by Microsoft.CodeAnalysis.PublicApiAnalyzers; CycloneDX SBOM and SLSA-style build-provenance attestation on every release. Recommended path for new code; supersedes the deprecated inline ML-KEM-only recipient mode (PQFE002) in the core package. - 1.5.0 — tracks the family in lockstep. PqHybridPrivateKey gains ExportEncrypted/ImportEncrypted (passphrase-protected PQKF key files, Argon2id by default, optional PqDecryptionLimits on import) and IsEncryptedKeyFile. PqHybridDecryptor gains a PqDecryptionLimits constructor, closing the last documented denial-of-service exposure for containers from untrusted sources. Hybrid recipient decryption is now pinned by a byte-exact known-answer vector. No change to the .pqfe v2 container format, which remains FROZEN for the 1.x line. See CHANGELOG.md. + Lockstep release of the PostQuantum.FileEncryption family — see CHANGELOG.md for the current version’s changes. Highlights from recent releases: tracks the family in lockstep. PqHybridPrivateKey gains ExportEncrypted/ImportEncrypted (passphrase-protected PQKF key files, Argon2id by default, optional PqDecryptionLimits on import) and IsEncryptedKeyFile. PqHybridDecryptor gains a PqDecryptionLimits constructor, closing the last documented denial-of-service exposure for containers from untrusted sources. Hybrid recipient decryption is now pinned by a byte-exact known-answer vector. No change to the .pqfe v2 container format, which remains FROZEN for the 1.x line. See CHANGELOG.md. post-quantum;pqc;ml-kem;ml-kem-768;kyber;x25519;hybrid;combiner;multi-recipient;encryption;file-encryption;recipient;authenticated-encryption;aead;cryptography;security;dotnet;net8;net10 MIT README.md diff --git a/src/PostQuantum.FileEncryption.Signing/PostQuantum.FileEncryption.Signing.csproj b/src/PostQuantum.FileEncryption.Signing/PostQuantum.FileEncryption.Signing.csproj index bce4f0a..19b0ec6 100644 --- a/src/PostQuantum.FileEncryption.Signing/PostQuantum.FileEncryption.Signing.csproj +++ b/src/PostQuantum.FileEncryption.Signing/PostQuantum.FileEncryption.Signing.csproj @@ -20,7 +20,7 @@ 6.0.0 PostQuantum.FileEncryption.Signing Open-source (MIT) post-quantum hybrid detached signatures for PostQuantum.FileEncryption, for .NET 8 and .NET 10. Signs any file or stream — typically a .pqfe container — with Ed25519 + ML-DSA-65 (FIPS 204) together and writes a small detached .sig sidecar, so a signature stays unforgeable if either primitive is later broken. Verification is fail-closed: both signatures must verify or PqSignatureException is thrown, with no oracle distinguishing why. Constant-memory streaming via SHA-512 pre-hash handles files of any size. The sidecar format is versioned and publicly specified (docs/SIGNATURE-FORMAT.md). Fully managed via BouncyCastle (no platform ML-DSA dependency); runs anywhere .NET 8 or later does. Adds sender authenticity on top of the encryption packages: AES-GCM proves a container was not altered, a detached signature proves who produced it. Public API surface locked by Microsoft.CodeAnalysis.PublicApiAnalyzers; CycloneDX SBOM and SLSA-style build-provenance attestation on every release. - 1.5.0 — tracks the family in lockstep. PqSigningPrivateKey gains ExportEncrypted/ImportEncrypted (passphrase-protected PQKF key files, docs/KEY-FILE-FORMAT.md) and IsEncryptedKeyFile. Verification no longer converts process-level faults (cancellation, thread interrupts) into PqSignatureException, so infrastructure failure is never reported as a forged signature. No change to the .sig sidecar format (v1) or the .pqfe v2 container format, which remain FROZEN for the 1.x line. See CHANGELOG.md. + Lockstep release of the PostQuantum.FileEncryption family — see CHANGELOG.md for the current version’s changes. Highlights from recent releases: tracks the family in lockstep. PqSigningPrivateKey gains ExportEncrypted/ImportEncrypted (passphrase-protected PQKF key files, docs/KEY-FILE-FORMAT.md) and IsEncryptedKeyFile. Verification no longer converts process-level faults (cancellation, thread interrupts) into PqSignatureException, so infrastructure failure is never reported as a forged signature. No change to the .sig sidecar format (v1) or the .pqfe v2 container format, which remain FROZEN for the 1.x line. See CHANGELOG.md. post-quantum;pqc;ml-dsa;ml-dsa-65;dilithium;ed25519;hybrid;signature;digital-signature;detached-signature;signing;verify;file-signing;authenticity;fips-204;cryptography;security;dotnet;net8;net10 MIT README.md diff --git a/src/PostQuantum.FileEncryption/IContentKeyProvider.cs b/src/PostQuantum.FileEncryption/IContentKeyProvider.cs index 2396a68..981c839 100644 --- a/src/PostQuantum.FileEncryption/IContentKeyProvider.cs +++ b/src/PostQuantum.FileEncryption/IContentKeyProvider.cs @@ -26,6 +26,14 @@ public interface IContentKeyProvider /// Generates a fresh 32-byte content key and the opaque wrapInfo bytes required to /// recover it. The caller owns the returned content key and will zero it after use. /// + /// + /// The content key MUST be freshly and randomly generated on every call — never cached, + /// pinned, or reused across calls. Cross-file AES-GCM nonce uniqueness rests entirely on + /// per-file key freshness (the container's nonce prefix is only 4 random bytes), so a + /// provider that returns a repeated key collapses nonce uniqueness to a 32-bit birthday + /// bound across files, leaking plaintext keystream and making the GCM authentication key + /// recoverable. See docs/KEY-MANAGEMENT.md and KNOWN-GAPS.md. + /// Task<(byte[] contentKey, byte[] wrapInfo)> WrapNewKeyAsync(CancellationToken cancellationToken = default); /// diff --git a/src/PostQuantum.FileEncryption/PostQuantum.FileEncryption.csproj b/src/PostQuantum.FileEncryption/PostQuantum.FileEncryption.csproj index 568790f..bfb02ff 100644 --- a/src/PostQuantum.FileEncryption/PostQuantum.FileEncryption.csproj +++ b/src/PostQuantum.FileEncryption/PostQuantum.FileEncryption.csproj @@ -23,7 +23,7 @@ 6.0.0 PostQuantum.FileEncryption Open-source (MIT), fail-closed file and stream encryption for .NET 8 and .NET 10. Constant-memory chunked streaming encrypts files of any size — multi-gigabyte backups and media run in roughly 130 KB of working memory — stream-to-stream or file-to-file, with authenticated AES-256-GCM, progress reporting, cancellation, atomic file output, and zeroable passphrases. Key derivation is PBKDF2-HMAC-SHA256 (OWASP-default 600,000 iterations) or Argon2id, with decrypt-time cost ceilings for untrusted input. The .pqfe v2 container format is FROZEN for the 1.x line and publicly specified — pinned by cross-implementation test vectors against a byte-compatible Rust/WASM reference, so containers are portable across platforms and implementations. AES-256 keeps data confidential against a harvest-now-decrypt-later quantum adversary; production post-quantum public-key encryption ships as the companion package PostQuantum.FileEncryption.Hybrid (X25519 + ML-KEM-768 combiner with multi-recipient support). The IContentKeyProvider envelope-key seam wraps content keys with an external KMS/HSM so the master key never enters your process. Non-sensitive EventSource telemetry for SIEM/OpenTelemetry. AOT-compatible; deterministic builds; SourceLink; CycloneDX SBOM and SLSA-style build-provenance attestation on every release; public API surface locked by Microsoft.CodeAnalysis.PublicApiAnalyzers. - 1.5.0 — the key-file and hardening release. Private keys gain a safe at-rest form: the PQKF passphrase-encrypted key-file format (docs/KEY-FILE-FORMAT.md) behind ExportEncrypted/ImportEncrypted in the Hybrid and Signing packages. A full-codebase security review tightened edges throughout: empty passphrases rejected on encrypt at one engine choke point (decrypt keeps opening legacy empty-passphrase containers), in-place file operations work on Windows, missing inputs fail before any destination side effect, atomic writes flush to stable storage off-thread, and provider-returned content keys are validated. Three new pinned known-answer vectors (hybrid recipient, multi-chunk, PQKF), mutation-testing-driven boundary tests, and a new Roslyn analyzers package (PQFE101-104). No format change: .pqfe v2 remains FROZEN for the 1.x line. See CHANGELOG.md. + Lockstep release of the PostQuantum.FileEncryption family — see CHANGELOG.md for the current version’s changes. Highlights from recent releases: the key-file and hardening release. Private keys gain a safe at-rest form: the PQKF passphrase-encrypted key-file format (docs/KEY-FILE-FORMAT.md) behind ExportEncrypted/ImportEncrypted in the Hybrid and Signing packages. A full-codebase security review tightened edges throughout: empty passphrases rejected on encrypt at one engine choke point (decrypt keeps opening legacy empty-passphrase containers), in-place file operations work on Windows, missing inputs fail before any destination side effect, atomic writes flush to stable storage off-thread, and provider-returned content keys are validated. Three new pinned known-answer vectors (hybrid recipient, multi-chunk, PQKF), mutation-testing-driven boundary tests, and a new Roslyn analyzers package (PQFE101-104). No format change: .pqfe v2 remains FROZEN for the 1.x line. See CHANGELOG.md. encryption;file-encryption;stream-encryption;large-files;aes-gcm;aes-256;argon2id;pbkdf2;passphrase;authenticated-encryption;aead;cryptography;security;streaming;post-quantum;pqc;ml-kem;harvest-now-decrypt-later;envelope-encryption;kms;hsm;dotnet;net8;net10;aot MIT README.md From 7ab27975c06ffe9f6e4f6bcfd57b73a78e994434 Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Fri, 4 Sep 2026 06:59:14 -0400 Subject: [PATCH 2/3] test+hardening: pin the untested defenses, cap what limits couldn't, heal the fuzzers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assurance batch from the merged review follow-through (multi-agent audit + two external reviews), all inside the 1.x format freeze — no byte meaning or reader acceptance changes; the corpus grows, nothing in it changes. Conformance corpus (+9 vectors, both implementations green): - Deterministic two-chunk positive pos-passphrase-pbkdf2-multichunk. - Argon2id bounds negatives (memory/iterations/parallelism/salt): the Rust reader's cost checks previously had zero test coverage anywhere. - Framing negatives: clean-boundary truncation (first exercise of the Rust saw_final path), frame swap (first Rust ordinal-binding coverage), dropped final frame, and a cross-container frame transplant backing SECURITY.md's "splicing between containers" claim with committed bytes. - The generator now reuses the pinned hybrid lenient vector instead of re-randomizing it on regen, and regeneration reproduces the corpus bit-for-bit (verified: regen produced only additions). Limits: PqDecryptionLimits.MaxArgon2Parallelism (default 255 = format maximum, so acceptance is unchanged; Untrusted caps at 8), enforced pre-derivation beside the memory/iteration ceilings. A hostile header could previously demand 255 Argon2 lanes and no limit could say no. Key-file hardening: PqKeyFileFormat.Decrypt output is a fixed-capacity buffer sized to the expected key rather than the whole file — kills the attacker-proportional allocation and the wrong-type reallocation that escaped the zeroing finally. Same exceptions, same messages. Fuzz health: both .NET targets now run under Untrusted limits (format-legal 2 GiB-KDF headers stalled single iterations ~20 min and failed 13/30 recent scheduled runs), plus -timeout=20 and timeout-*/oom-* artifact upload in CI. Tests: ZeroizationTests (engine success/failure/cancellation + Dispose paths — a documented defense with previously zero coverage that has regressed before), cross-container transplant, KeySource-4 block swap/strip/duplicate, SHA256SUMS integrity (was verified by nothing), Argon2 parallelism limits. Suite: 636 passing across net8.0+net10.0; cargo conformance suite green. Hygiene: analyzer rules PQFE101-104 moved to the shipped ledger (1.5.0); System.Formats.Asn1 transitive floored at 8.0.1 in the analyzer test project (CVE-2024-38095, test-only). Docs made true: SECURITY.md discloses the stage-distinguishable error caveat (key-unwrap vs body-auth — recorded in KNOWN-GAPS with the unification decision explicitly open); CONFORMANCE.md §3 names the corpus vector for each MUST; REPRODUCIBLE-BUILDS.md retracts the cross-OS claim — a macOS arm64 rebuild of v1.7.1 differs from the published package (reproduced locally with the repo's own verifier; Linux CI remains the demonstrated envelope); README says "provenance-attested releases" (there is no author/tag signing). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sym7RJ7ehNhbMXytE5rMmS --- .github/workflows/fuzz.yml | 11 +- CHANGELOG.md | 50 ++++++++ KNOWN-GAPS.md | 14 +++ README.md | 2 +- SECURITY.md | 7 +- docs/CONFORMANCE.md | 32 +++-- docs/FUZZING.md | 9 +- docs/REPRODUCIBLE-BUILDS.md | 16 ++- .../Program.cs | 9 +- samples/pqfe-wasm/tests/conformance.rs | 20 +++ .../AnalyzerReleases.Shipped.md | 11 ++ .../AnalyzerReleases.Unshipped.md | 9 -- .../Internal/KeyEstablishment.cs | 5 + .../Internal/PqKeyFileFormat.cs | 35 ++++-- .../PqDecryptionLimits.cs | 19 ++- .../PublicAPI.Unshipped.txt | 2 + test-vectors/SHA256SUMS | 1 + test-vectors/manifest.json | 99 +++++++++++++++ .../argon2-iterations-out-of-range.pqfe | Bin 0 -> 116 bytes .../negative/argon2-memory-out-of-range.pqfe | Bin 0 -> 116 bytes .../negative/argon2-parallelism-zero.pqfe | Bin 0 -> 116 bytes .../negative/cross-container-transplant.pqfe | Bin 0 -> 2130 bytes .../negative/final-frame-dropped.pqfe | Bin 0 -> 1085 bytes test-vectors/negative/frame-swap.pqfe | Bin 0 -> 2130 bytes test-vectors/negative/salt-too-short.pqfe | Bin 0 -> 116 bytes .../negative/truncated-at-frame-boundary.pqfe | Bin 0 -> 40 bytes .../passphrase-pbkdf2-multichunk.pqfe | Bin 0 -> 2130 bytes ...ntum.FileEncryption.Analyzers.Tests.csproj | 3 + .../ConformanceVectors.cs | 114 ++++++++++++++++- .../DecryptionLimitsTests.cs | 59 +++++++++ .../ErrorHandlingTests.cs | 23 ++++ .../HybridTests.cs | 94 ++++++++++++++ .../VectorArtifactTests.cs | 40 ++++++ .../ZeroizationTests.cs | 117 ++++++++++++++++++ 34 files changed, 752 insertions(+), 49 deletions(-) create mode 100644 test-vectors/negative/argon2-iterations-out-of-range.pqfe create mode 100644 test-vectors/negative/argon2-memory-out-of-range.pqfe create mode 100644 test-vectors/negative/argon2-parallelism-zero.pqfe create mode 100644 test-vectors/negative/cross-container-transplant.pqfe create mode 100644 test-vectors/negative/final-frame-dropped.pqfe create mode 100644 test-vectors/negative/frame-swap.pqfe create mode 100644 test-vectors/negative/salt-too-short.pqfe create mode 100644 test-vectors/negative/truncated-at-frame-boundary.pqfe create mode 100644 test-vectors/passphrase-pbkdf2-multichunk.pqfe create mode 100644 tests/PostQuantum.FileEncryption.Tests/ZeroizationTests.cs diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 1a02cfd..9c1d54b 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -51,7 +51,7 @@ jobs: working-directory: samples/pqfe-wasm run: | DURATION="${{ github.event.inputs.duration_seconds || '300' }}" - cargo +nightly fuzz run decrypt -- -max_total_time="$DURATION" -rss_limit_mb=4096 + cargo +nightly fuzz run decrypt -- -max_total_time="$DURATION" -rss_limit_mb=4096 -timeout=20 - name: Upload crash artifacts if: failure() @@ -101,11 +101,16 @@ jobs: # a PQKF key file); -n preserves cache-restored, coverage-accumulated inputs. cp -n fuzz/PostQuantum.FileEncryption.Fuzz/seed-corpus/*.pqfe fuzz/PostQuantum.FileEncryption.Fuzz/seed-corpus/*.pqkf dotnet-corpus/ 2>/dev/null || true ./libfuzzer-dotnet --target_path=dotnet --target_arg="$PWD/fuzzpub/PostQuantum.FileEncryption.Fuzz.dll" \ - dotnet-corpus -max_total_time="$DURATION" -rss_limit_mb=4096 + dotnet-corpus -max_total_time="$DURATION" -rss_limit_mb=4096 -timeout=20 - name: Upload crash artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: sharpfuzz-crashes - path: crash-* + # libFuzzer writes crash-*, timeout-*, and oom-* reproducers to the working + # directory — upload all three shapes, not just crashes. + path: | + crash-* + timeout-* + oom-* diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a87f7..fc3182f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,31 @@ and the `.pqfe` v2 container format is frozen for the entire `1.x` line. ### Added +- **Nine new conformance-corpus vectors, run by both implementations.** A deterministic + two-chunk positive (`pos-passphrase-pbkdf2-multichunk`) plus eight negatives: the Argon2id + cost/salt bounds (`neg-argon2-memory-out-of-range`, `neg-argon2-iterations-out-of-range`, + `neg-argon2-parallelism-zero`, `neg-salt-too-short`) — previously pinned only by .NET unit + tests, so a Rust-side bounds regression would have shipped undetected — and four framing + negatives (`neg-truncated-at-frame-boundary`, `neg-frame-swap`, `neg-final-frame-dropped`, + `neg-cross-container-transplant`) that give the Rust reader its first coverage of the + final-frame gate, ordinal binding, and cross-container splicing, and back SECURITY.md's + explicit "splicing between containers" claim with committed bytes. All are deterministic + derivations; regenerating the corpus reproduces them bit-for-bit, and the generator now + refuses to re-randomize the pinned hybrid lenient vector. +- **`PqDecryptionLimits.MaxArgon2Parallelism`.** A hostile header could demand Argon2id + parallelism 255 and no limit could cap it — even `Untrusted`. The new limit defaults to 255 + (the format maximum, so no acceptance change) and `Untrusted` now caps it at 8. +- **Zeroization regression tests** (`ZeroizationTests`): the engine's content-key zeroing on + the success, authentication-failure, and cancellation paths, plus + `PqRecipientPrivateKey.Dispose` and `LocalKekContentKeyProvider.Dispose` — a documented + defense that previously had zero test coverage and has regressed before (2026-06-12 audits). +- **Structural-attack tests:** cross-container chunk transplant (unit + corpus), and + KeySource-4 recipient-block swap / strip / duplicate — the manipulation class + HYBRID-COMBINER.md points auditors at, previously untested. +- **`test-vectors/SHA256SUMS` is now verified by CI** (every line recomputed, every top-level + artifact required to be listed) — it existed for third-party verification but was itself + checked by nothing. + - **The documentation-consistency CI guard now actually exists.** The `1.7.0` changelog described a `docs-consistency` workflow running `scripts/check-docs-consistency.sh` on every push and pull request, but the workflow file was never committed — the guard never ran, which is how the @@ -26,6 +51,31 @@ and the `.pqfe` v2 container format is frozen for the entire `1.x` line. ### Changed +- **Both fuzz targets now run under `PqDecryptionLimits.Untrusted`, with per-input timeouts.** + The container target fuzzed at the permissive Default limits, so the fuzzer minted + format-legal headers demanding gigabyte-scale KDF work — single inputs stalled ~20 minutes + and failed 13 of the last 30 scheduled runs on timeout. The workflow also passes + `-timeout=20` and uploads `timeout-*`/`oom-*` reproducers alongside `crash-*`. +- **`PqKeyFileFormat.Decrypt` uses a fixed-capacity output buffer** sized to the expected key + (+1 byte) instead of preallocating to the whole key file's length: a hostile key file can no + longer force an output allocation proportional to its own size, and a real-but-wrong-type + key's bytes can no longer land in a reallocated buffer the zeroing `finally` doesn't hold. + Behavior is unchanged: oversized or wrong-type plaintext still fails with the same + `PqFormatException`. +- **Analyzer release ledger corrected:** PQFE101–PQFE104 moved from + `AnalyzerReleases.Unshipped.md` to the shipped ledger under 1.5.0, where they actually + shipped. The analyzer test project also floors the transitive `System.Formats.Asn1` at + 8.0.1 (CVE-2024-38095 — test-only graph, kept clean anyway). +- **SECURITY.md now discloses the one key-dependent error distinction** (key-unwrap failure + vs. body-authentication failure are distinguishable stages — a key-possession signal for + services that expose raw errors), recorded in KNOWN-GAPS.md with the unify-or-keep decision + explicitly open. CONFORMANCE.md §3's MUST-reject list now names the committed corpus vector + for each case, FUZZING.md reflects the capped harnesses, README says "provenance-attested + releases" instead of "signed releases" (no author/tag signing is configured), and + REPRODUCIBLE-BUILDS.md scopes the reproducibility claim to Linux — a macOS arm64 rebuild of + `v1.7.1` reproducibly differs from the published package (verified with the repo's own + script), so the cross-OS claim was retracted until root-caused. + - **KEY-MANAGEMENT.md no longer promises header-only CEK rewrap on format v2.** The serialized header is bound as AAD into every content frame, so replacing the wrapped CEK forces every frame to be re-authenticated — and doing that under the *same* CEK and nonces would emit second GCM diff --git a/KNOWN-GAPS.md b/KNOWN-GAPS.md index f246c56..6717a0b 100644 --- a/KNOWN-GAPS.md +++ b/KNOWN-GAPS.md @@ -122,6 +122,20 @@ Last reviewed against: **`1.7.1`**. See [ROADMAP.md](ROADMAP.md) for the forward - **Passphrases are still `string` on the convenience overloads.** The zeroable byte overloads exist, but the `string` overloads remain for ergonomics and cannot zero the caller's `string`. +- **The failure *stage* is distinguishable across key-establishment and body authentication.** + Within each stage messages are uniform and pinned by tests (wrong passphrase vs. tampered + bytes is one identical message), but the stages differ: a hybrid/provider key that fails to + *unwrap* the content key throws "not encrypted to this key…" while a successfully unwrapped + container whose *body* fails authentication throws the generic tamper message (with a + different inner exception). An external probe confirmed the two are programmatically + distinguishable. That tells an attacker submitting a crafted container to a service whether + the service's key could open it — a key-possession/membership signal, though never a + plaintext or key-recovery oracle. Unifying the public message and inner-exception policy + across stages is `1.x`-safe (exception text is not part of the frozen format) but changes + observable behavior callers may match on, so it is deliberately a considered decision rather + than a quiet patch; until then, services should log raw exception detail privately and + return a uniform error to untrusted callers. + ### Dependency assurance - **Argon2id comes from `Konscious.Security.Cryptography`**, a widely used but **not formally diff --git a/README.md b/README.md index 32eb67c..7842c3c 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,7 @@ boundaries, assumptions, and explicit non-goals — is published in decrypt-later adversary) and a clear path to **post-quantum public-key encryption** via the Hybrid package. - You want **enterprise affordances**: telemetry, atomic output, a documented format with - test vectors, a published threat model, signed releases, and a locked API. + test vectors, a published threat model, provenance-attested releases, and a locked API. - You want a [comparison vs. `age`, libsodium, and OpenSSL](https://github.com/systemslibrarian/postquantum-file-encryption/blob/main/docs/COMPARISON.md) before committing. diff --git a/SECURITY.md b/SECURITY.md index 575be12..61baf6b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -56,7 +56,12 @@ report until a fix is available and coordinated. ciphertext) carries one identical message — the library never distinguishes "wrong key" from "tampered data" at the public surface, and tests pin those messages byte-identical. Structural failures that an attacker can already compute from the ciphertext alone - (truncation, corrupt framing) carry distinct but key-independent diagnostics. + (truncation, corrupt framing) carry distinct but key-independent diagnostics. One + distinction *is* key-dependent: which stage failed. A recipient/provider key that cannot + unwrap the content key reports differently from a body that fails chunk authentication, so + a service that exposes raw error details can reveal whether its key could open a submitted + container — see [KNOWN-GAPS.md](KNOWN-GAPS.md) before surfacing exception text across a + trust boundary. - **No partial output on failure.** File APIs stage every byte to a sibling temp file and only `File.Move` it into place on full success. Stream callers can opt into the same all-or-nothing guarantee via `DecryptAtomicAsync`. diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index b452d08..bbc38e1 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -149,17 +149,27 @@ Coverage table: | `4` | Multiple recipients | `tests/.../HybridTests.cs` | | `5` | Envelope key provider (`LocalKekContentKeyProvider`) | `tests/.../KeyProviderTests.cs` | -Negative vectors (every implementation MUST reject these the same way): - -- Wrong passphrase -- Wrong recipient private key -- Wrong content-key provider -- A single bit flip in the header -- A single bit flip in any frame's ciphertext or tag -- Truncation: drop the final frame entirely, drop the final N bytes of the final tag -- Splicing: swap frame *i* with frame *j* -- Out-of-range KDF parameters in the header (e.g. `Pbkdf2Iterations = 1`) -- Unknown `AeadId`, `KeySource`, or `FormatVersion` +Negative vectors (every implementation MUST reject these the same way). Most are committed as +machine-readable corpus vectors (`test-vectors/manifest.json`), run by both implementations: + +- Wrong passphrase — corpus `neg-wrong-passphrase` +- Wrong recipient private key — per-implementation unit tests (`HybridTests`; randomized keys, + so not corpus-pinnable) +- Wrong content-key provider — .NET unit tests (`KeyProviderTests`; the Rust core does not + implement `KeySource = 5`) +- A single bit flip in the header — corpus `neg-header-tamper` +- A single bit flip in any frame's ciphertext or tag — corpus `neg-ciphertext-tamper` +- Truncation — corpus `neg-tag-truncated`, `neg-prefix-truncated`, + `neg-truncated-at-frame-boundary` (clean cut, zero frames), and `neg-final-frame-dropped` + (clean cut after an authentic non-final frame) +- Splicing: swap frame *i* with frame *j* — corpus `neg-frame-swap`; transplanting an authentic + frame from a *different* container — corpus `neg-cross-container-transplant` +- Out-of-range KDF parameters in the header — corpus `neg-pbkdf2-iterations-out-of-range`, + `neg-argon2-memory-out-of-range`, `neg-argon2-iterations-out-of-range`, + `neg-argon2-parallelism-zero`, and `neg-salt-too-short` +- Unknown `AeadId`, `KeySource`, or `FormatVersion` — corpus `neg-unknown-aead`, + `neg-unknown-keysource`, `neg-bad-version` (and `neg-bad-magic`, `neg-not-a-container`, + `neg-chunksize-zero`) --- diff --git a/docs/FUZZING.md b/docs/FUZZING.md index 6b06fb9..3347d81 100644 --- a/docs/FUZZING.md +++ b/docs/FUZZING.md @@ -80,8 +80,13 @@ mkdir -p corpus && cp fuzz/PostQuantum.FileEncryption.Fuzz/seed-corpus/*.pqfe \ > instrumented method (even a constructor) earlier would crash the harness, not the parser. > Each iteration feeds the same input to **both** .NET targets — the container parser and the > `PQKF` encrypted key-file parser (the framing check fails fast on non-`PQKF` input, so the -> second target is nearly free). The key-file target runs under `PqDecryptionLimits.Untrusted` -> so a fuzzer-crafted Argon2id header cannot turn one iteration into a gigabyte-scale KDF. +> second target is nearly free). **Both targets run under `PqDecryptionLimits.Untrusted`** so a +> fuzzer-crafted (but format-legal) Argon2id header cannot turn one iteration into a +> gigabyte-scale, multi-minute KDF — exactly that shape stalled scheduled runs for ~20 minutes +> per input and failed them on timeout before the container target was capped. The +> format-maxima range checks that the permissive Default limits would exercise are pinned by +> `ParserBoundaryTests` and the committed negative vectors instead. The CI jobs also pass +> `-timeout=20` per input and upload `timeout-*`/`oom-*` reproducers alongside `crash-*`. ## Scheduled CI diff --git a/docs/REPRODUCIBLE-BUILDS.md b/docs/REPRODUCIBLE-BUILDS.md index f4bd5d8..86a969a 100644 --- a/docs/REPRODUCIBLE-BUILDS.md +++ b/docs/REPRODUCIBLE-BUILDS.md @@ -50,12 +50,22 @@ The project pins every known source of non-determinism: What does **not** affect reproducibility (with the above in place): -- The operating system of the *verifier*. With `.gitattributes` normalising source line - endings on checkout, the source the compiler sees is byte-identical on Linux, macOS, and - Windows, and `Deterministic=true` then produces byte-identical assemblies. - Local NuGet caches. The compiler reads from `obj/`, which is regenerated from the same inputs. +What **should not** affect it, but currently does: + +- **The operating system of the verifier.** In principle `.gitattributes`-normalised sources + plus `Deterministic=true` yield byte-identical assemblies on any OS. In practice, a macOS + arm64 rebuild of `v1.7.1` produced managed DLLs that differ from the Linux-built published + package — observed with this repository's own verifier, and independently reported. The + release-time verification on Linux passed for the same tag, so this is platform/toolchain + drift, not evidence of tampering — but it means **Linux (the CI environment) is the + demonstrated reproducibility envelope today**. Verify on Linux, or when investigating a + mismatch elsewhere, first match the exact SDK (`dotnet --version`) and OS of the release + build before suspecting the artifact. Root-causing (and either fixing or permanently + scoping) the cross-OS difference is tracked work. + What **does** matter: - The verifier must check the repo out at the release tag *after* `.gitattributes` was diff --git a/fuzz/PostQuantum.FileEncryption.Fuzz/Program.cs b/fuzz/PostQuantum.FileEncryption.Fuzz/Program.cs index a6b6ab4..4a292f8 100644 --- a/fuzz/PostQuantum.FileEncryption.Fuzz/Program.cs +++ b/fuzz/PostQuantum.FileEncryption.Fuzz/Program.cs @@ -13,11 +13,16 @@ // (even a constructor) beforehand would write to unmapped memory. Fuzzer.LibFuzzer.Run(data => { - // Target 1: the .pqfe v2 container parser. + // Target 1: the .pqfe v2 container parser. Untrusted limits, for the same reason as the + // PQKF target below — and because the permissive Default limits let the fuzzer mint + // format-legal headers demanding 2 GiB Argon2id / 100M PBKDF2 iterations, which stalled + // single iterations for ~20 minutes and failed scheduled runs on timeout. The format-maxima + // range checks the Default path exercises are pinned by ParserBoundaryTests instead. try { // Synchronous wait is fine in a fuzz harness; the call is CPU-bound over a byte buffer. - _ = new PqFileDecryptor().DecryptBytesAsync(data.ToArray(), passphrase).GetAwaiter().GetResult(); + _ = new PqFileDecryptor(PqDecryptionLimits.Untrusted) + .DecryptBytesAsync(data.ToArray(), passphrase).GetAwaiter().GetResult(); } catch (PqEncryptionException) { diff --git a/samples/pqfe-wasm/tests/conformance.rs b/samples/pqfe-wasm/tests/conformance.rs index 149aec0..19eb1e4 100644 --- a/samples/pqfe-wasm/tests/conformance.rs +++ b/samples/pqfe-wasm/tests/conformance.rs @@ -75,6 +75,14 @@ fn lenient_corners_are_accepted() { ); } +#[test] +fn multichunk_positive_vector_decrypts() { + // Deterministic two-chunk container: pins frame ordering, the final-frame marker, and + // multi-frame decryption. Its expected plaintext is 2048 bytes of a repeated sentence. + let expected = "PQFE multi-chunk conformance vector. ".repeat(56).into_bytes(); + accept("passphrase-pbkdf2-multichunk.pqfe", PASSPHRASE, &expected[..2048]); +} + #[test] fn negative_vectors_are_rejected() { for rel in [ @@ -89,6 +97,18 @@ fn negative_vectors_are_rejected() { "negative/tag-truncated.pqfe", "negative/prefix-truncated.pqfe", "negative/not-a-container.bin", + // Argon2id cost/salt bounds (CONFORMANCE.md 2.1 rule 5, Argon2id side) — the checks + // that stop a tiny hostile header from demanding 2 GiB of memory, pinned per reader. + "negative/argon2-memory-out-of-range.pqfe", + "negative/argon2-iterations-out-of-range.pqfe", + "negative/argon2-parallelism-zero.pqfe", + "negative/salt-too-short.pqfe", + // Framing negatives: clean-boundary truncation (the saw_final path), frame reorder + // (counter-in-AAD), a dropped final frame, and a cross-container frame transplant. + "negative/truncated-at-frame-boundary.pqfe", + "negative/frame-swap.pqfe", + "negative/final-frame-dropped.pqfe", + "negative/cross-container-transplant.pqfe", ] { reject(rel, PASSPHRASE); } diff --git a/src/PostQuantum.FileEncryption.Analyzers/AnalyzerReleases.Shipped.md b/src/PostQuantum.FileEncryption.Analyzers/AnalyzerReleases.Shipped.md index f50bb1f..256cc99 100644 --- a/src/PostQuantum.FileEncryption.Analyzers/AnalyzerReleases.Shipped.md +++ b/src/PostQuantum.FileEncryption.Analyzers/AnalyzerReleases.Shipped.md @@ -1,2 +1,13 @@ ; Shipped analyzer releases ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +## Release 1.5.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +PQFE101 | Security | Warning | Passphrase is a compile-time constant +PQFE102 | Security | Warning | Raw private-key bytes written to disk +PQFE103 | Security | Warning | Encrypt/decrypt/sign/verify task is discarded +PQFE104 | Security | Warning | Fail-closed exception is silently swallowed diff --git a/src/PostQuantum.FileEncryption.Analyzers/AnalyzerReleases.Unshipped.md b/src/PostQuantum.FileEncryption.Analyzers/AnalyzerReleases.Unshipped.md index 4d36a44..f2b7fad 100644 --- a/src/PostQuantum.FileEncryption.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/PostQuantum.FileEncryption.Analyzers/AnalyzerReleases.Unshipped.md @@ -1,11 +1,2 @@ ; Unshipped analyzer release ; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md - -### New Rules - -Rule ID | Category | Severity | Notes ---------|----------|----------|------- -PQFE101 | Security | Warning | Passphrase is a compile-time constant -PQFE102 | Security | Warning | Raw private-key bytes written to disk -PQFE103 | Security | Warning | Encrypt/decrypt/sign/verify task is discarded -PQFE104 | Security | Warning | Fail-closed exception is silently swallowed diff --git a/src/PostQuantum.FileEncryption/Internal/KeyEstablishment.cs b/src/PostQuantum.FileEncryption/Internal/KeyEstablishment.cs index 1d46b73..bcbaad6 100644 --- a/src/PostQuantum.FileEncryption/Internal/KeyEstablishment.cs +++ b/src/PostQuantum.FileEncryption/Internal/KeyEstablishment.cs @@ -116,6 +116,11 @@ public static async Task DerivePassphraseKeyAsync( throw new PqFormatException( $"Container demands Argon2id with {memoryKiB} KiB memory and {iterations} iterations, above this decryptor's configured limits of {limits.MaxArgon2MemoryKiB} KiB / {limits.MaxArgon2Iterations} (see PqDecryptionLimits)."); } + if (parallelism > limits.MaxArgon2Parallelism) + { + throw new PqFormatException( + $"Container demands Argon2id parallelism {parallelism}, above this decryptor's configured limit of {limits.MaxArgon2Parallelism} (see PqDecryptionLimits)."); + } return await DeriveArgon2idAsync(passphrase, salt, (int)memoryKiB, (int)iterations, parallelism).ConfigureAwait(false); } default: diff --git a/src/PostQuantum.FileEncryption/Internal/PqKeyFileFormat.cs b/src/PostQuantum.FileEncryption/Internal/PqKeyFileFormat.cs index 454c45d..dded9de 100644 --- a/src/PostQuantum.FileEncryption/Internal/PqKeyFileFormat.cs +++ b/src/PostQuantum.FileEncryption/Internal/PqKeyFileFormat.cs @@ -102,29 +102,44 @@ public static byte[] Decrypt( (limits ?? PqDecryptionLimits.Default).Validate(); byte[] passphraseBytes = new byte[Encoding.UTF8.GetByteCount(passphrase)]; - // Sized so the backing buffer never reallocates (the plaintext is always smaller than - // the container), leaving exactly one key-bearing buffer for the finally to zero. - using var output = new MemoryStream(keyFile.Length); + // Fixed-capacity output: a valid key file's plaintext is exactly 1 + expectedKeyLength + // bytes, so cap the buffer there (+1 so an oversized plaintext is detectable). A fixed + // buffer never reallocates, which keeps exactly one key-bearing buffer for the finally + // to zero — including when the file holds a *different* kind of real key — and stops a + // hostile key file from forcing an output allocation proportional to its own size. + byte[] outputBuffer = new byte[1 + expectedKeyLength + 1]; try { + using var output = new MemoryStream(outputBuffer, 0, outputBuffer.Length, writable: true); + output.SetLength(0); Encoding.UTF8.GetBytes(passphrase, passphraseBytes); using var input = new MemoryStream(keyFile[HeaderLength..].ToArray(), writable: false); - PqContainer.DecryptPassphraseAsync( - input, output, passphraseBytes, limits ?? PqDecryptionLimits.Default, - input.Length, progress: null, CancellationToken.None) - .GetAwaiter().GetResult(); + try + { + PqContainer.DecryptPassphraseAsync( + input, output, passphraseBytes, limits ?? PqDecryptionLimits.Default, + input.Length, progress: null, CancellationToken.None) + .GetAwaiter().GetResult(); + } + catch (NotSupportedException) + { + // The fixed-capacity stream refused a write past 1 + expectedKeyLength + 1: + // the (authenticated) plaintext is larger than any key of the expected type, + // so this is the same caller-error signal as the length check below. + throw new PqFormatException($"This encrypted key file does not hold a {expectedTypeName}."); + } // The type byte was authenticated as part of the container plaintext, so this is a // caller-error signal (right passphrase, wrong kind of key file), not an oracle. - if (output.Length != 1 + expectedKeyLength || output.GetBuffer()[0] != expectedKeyType) + if (output.Length != 1 + expectedKeyLength || outputBuffer[0] != expectedKeyType) { throw new PqFormatException($"This encrypted key file does not hold a {expectedTypeName}."); } - return output.GetBuffer().AsSpan(1, expectedKeyLength).ToArray(); + return outputBuffer.AsSpan(1, expectedKeyLength).ToArray(); } finally { - CryptographicOperations.ZeroMemory(output.GetBuffer()); + CryptographicOperations.ZeroMemory(outputBuffer); CryptographicOperations.ZeroMemory(passphraseBytes); } } diff --git a/src/PostQuantum.FileEncryption/PqDecryptionLimits.cs b/src/PostQuantum.FileEncryption/PqDecryptionLimits.cs index bfb0e05..f74453c 100644 --- a/src/PostQuantum.FileEncryption/PqDecryptionLimits.cs +++ b/src/PostQuantum.FileEncryption/PqDecryptionLimits.cs @@ -36,6 +36,15 @@ public sealed class PqDecryptionLimits /// public int MaxArgon2Iterations { get; init; } = PqEncryptionOptions.MaxArgon2Iterations; + /// + /// Highest Argon2id parallelism (lane count) this decryptor will honor. The on-disk field + /// is one byte, so the format maximum — and the default — is 255. Parallelism divides + /// rather than multiplies the total Argon2id work, so this is a scheduling-overhead + /// ceiling, not a memory/CPU one; the memory and iteration limits above are the primary + /// cost bounds. + /// + public int MaxArgon2Parallelism { get; init; } = 255; + /// /// Largest chunk size, in bytes, this decryptor will allocate buffers for. /// Defaults to the format maximum (16 MiB). @@ -47,7 +56,7 @@ public sealed class PqDecryptionLimits /// /// A conservative preset for containers from untrusted sources: PBKDF2 ≤ 2,000,000 - /// iterations, Argon2id ≤ 256 MiB memory and ≤ 10 passes, chunk size ≤ 4 MiB. Every + /// iterations, Argon2id ≤ 256 MiB memory, ≤ 10 passes, and ≤ 8 lanes, chunk size ≤ 4 MiB. Every /// container produced with this library's defaults (and any reasonable tuning) stays well /// inside these ceilings; what gets rejected is the pathological header crafted to make /// you burn gibibytes of memory or minutes of CPU before the first authentication check. @@ -57,6 +66,7 @@ public sealed class PqDecryptionLimits MaxPbkdf2Iterations = 2_000_000, MaxArgon2MemoryKiB = 256 * 1024, MaxArgon2Iterations = 10, + MaxArgon2Parallelism = 8, MaxChunkSizeBytes = 4 * 1024 * 1024, }; @@ -91,6 +101,13 @@ internal void Validate() $"Limit must be between {PqEncryptionOptions.MinArgon2Iterations} and {PqEncryptionOptions.MaxArgon2Iterations}."); } + if (MaxArgon2Parallelism < 1 || MaxArgon2Parallelism > 255) + { + throw new ArgumentOutOfRangeException( + nameof(MaxArgon2Parallelism), MaxArgon2Parallelism, + "Limit must be between 1 and 255."); + } + if (MaxChunkSizeBytes < PqEncryptionOptions.MinChunkSizeBytes || MaxChunkSizeBytes > PqEncryptionOptions.MaxChunkSizeBytes) { diff --git a/src/PostQuantum.FileEncryption/PublicAPI.Unshipped.txt b/src/PostQuantum.FileEncryption/PublicAPI.Unshipped.txt index 7dc5c58..730c068 100644 --- a/src/PostQuantum.FileEncryption/PublicAPI.Unshipped.txt +++ b/src/PostQuantum.FileEncryption/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +PostQuantum.FileEncryption.PqDecryptionLimits.MaxArgon2Parallelism.get -> int +PostQuantum.FileEncryption.PqDecryptionLimits.MaxArgon2Parallelism.init -> void diff --git a/test-vectors/SHA256SUMS b/test-vectors/SHA256SUMS index 2a7eeff..e4990d8 100644 --- a/test-vectors/SHA256SUMS +++ b/test-vectors/SHA256SUMS @@ -2,4 +2,5 @@ a16ff8db3dad6a50d9a81cee5a97ce26d875c8dce80a00c93dd7516f080d31de *hybrid-recipie 4e165d1238fcad436bad8b7cd72072b9196e4492aaddfcddfbc82029f0eca4ee *passphrase-argon2id.pqfe ab32cc1d2f5f673d77d80fc2f45307abe4a33a35552f2b5c677a9c5818718547 *passphrase-pbkdf2.pqfe b428f6492c78fe03b8b3197872e60bd737764be066cdaddab594f06f18e6ade6 *passphrase-pbkdf2-rustcore.pqfe +008543ab899a427a33100f1b865b54c5ef0426834b8e7b696eeffc9b97ea886a *passphrase-pbkdf2-multichunk.pqfe eeda08e328b028e69f87145642c7898c72be83e410ebfd595f0b2b50fd9bfb38 *keyfile.pqkf diff --git a/test-vectors/manifest.json b/test-vectors/manifest.json index c890b12..d25934a 100644 --- a/test-vectors/manifest.json +++ b/test-vectors/manifest.json @@ -35,6 +35,17 @@ "plaintextUtf8": "Encrypted by the Rust/WASM core, decrypted by .NET.", "notes": "Frozen KAT Vector 3 (produced by the Rust core)." }, + { + "id": "pos-passphrase-pbkdf2-multichunk", + "category": "positive", + "expect": "accept", + "file": "passphrase-pbkdf2-multichunk.pqfe", + "sha256": "008543ab899a427a33100f1b865b54c5ef0426834b8e7b696eeffc9b97ea886a", + "keySource": 1, + "passphrase": "test-vector-passphrase", + "plaintextUtf8": "PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-chunk conformance vector. PQFE multi-ch", + "notes": "Deterministic two-chunk container (1024-byte chunks, PBKDF2 100k): pins frame ordering, the final-frame marker, and multi-frame decryption cross-implementation." + }, { "id": "neg-bad-magic", "category": "negative", @@ -156,6 +167,94 @@ "derivedFrom": "passphrase-pbkdf2.pqfe", "notes": "64 zero bytes: no PQFE magic." }, + { + "id": "neg-argon2-memory-out-of-range", + "category": "negative", + "expect": "reject-format", + "file": "negative/argon2-memory-out-of-range.pqfe", + "sha256": "69f49c27f8a79c60ec592bc40e78f7e46e4fb540cb9ded7bc5e5d7388bab6099", + "keySource": 1, + "passphrase": "test-vector-passphrase", + "derivedFrom": "passphrase-argon2id.pqfe", + "notes": "Argon2id memory set to 2,097,153 KiB \u2014 one above the format maximum; rejected before any derivation." + }, + { + "id": "neg-argon2-iterations-out-of-range", + "category": "negative", + "expect": "reject-format", + "file": "negative/argon2-iterations-out-of-range.pqfe", + "sha256": "67aeb5e2ec92fbed19805f275792f3d23d65fd4fac92480e91fb457429d6ae04", + "keySource": 1, + "passphrase": "test-vector-passphrase", + "derivedFrom": "passphrase-argon2id.pqfe", + "notes": "Argon2id iterations set to 10,001 \u2014 one above the format maximum; rejected before any derivation." + }, + { + "id": "neg-argon2-parallelism-zero", + "category": "negative", + "expect": "reject-format", + "file": "negative/argon2-parallelism-zero.pqfe", + "sha256": "6c56513c67f156c408dc54a48313bad9061622a09fe1d3ffa329f88244d13ac5", + "keySource": 1, + "passphrase": "test-vector-passphrase", + "derivedFrom": "passphrase-argon2id.pqfe", + "notes": "Argon2id parallelism set to 0 \u2014 below the minimum of 1; rejected before any derivation." + }, + { + "id": "neg-salt-too-short", + "category": "negative", + "expect": "reject-format", + "file": "negative/salt-too-short.pqfe", + "sha256": "e893220cedf4be01c32b100e0db6273e1b114d57cb206db5cadde74e056d9177", + "keySource": 1, + "passphrase": "test-vector-passphrase", + "derivedFrom": "passphrase-argon2id.pqfe", + "notes": "Declared salt length set to 7 \u2014 below the 8-byte floor; rejected before any derivation." + }, + { + "id": "neg-truncated-at-frame-boundary", + "category": "negative", + "expect": "reject-decryption", + "file": "negative/truncated-at-frame-boundary.pqfe", + "sha256": "b2c86ff20b2fd4527cf94596409e1c3b7829865938726a5dd85b0a2380a47a1a", + "keySource": 1, + "passphrase": "test-vector-passphrase", + "derivedFrom": "passphrase-pbkdf2.pqfe", + "notes": "Container cut exactly at the header/frame boundary: parses cleanly but carries no authenticated final frame, so a conforming reader must reject it." + }, + { + "id": "neg-frame-swap", + "category": "negative", + "expect": "reject-decryption", + "file": "negative/frame-swap.pqfe", + "sha256": "73ebecb7ab75eb9adf570a1a1f46d25ff490ca89b39acabe19e56bf531fc247d", + "keySource": 1, + "passphrase": "test-vector-passphrase", + "derivedFrom": "passphrase-pbkdf2-multichunk.pqfe", + "notes": "The two frames of the multi-chunk vector swapped on disk: each frame\u0027s ordinal is bound as AAD, so reordering fails authentication." + }, + { + "id": "neg-final-frame-dropped", + "category": "negative", + "expect": "reject-decryption", + "file": "negative/final-frame-dropped.pqfe", + "sha256": "57d2d3f47c88f7f7dc5f6d332ba2d119143300175ed4210c2d4d4b2ece839ed2", + "keySource": 1, + "passphrase": "test-vector-passphrase", + "derivedFrom": "passphrase-pbkdf2-multichunk.pqfe", + "notes": "The multi-chunk vector cut cleanly after its first (authentic, non-final) frame: no authenticated final marker, so a conforming reader must reject it." + }, + { + "id": "neg-cross-container-transplant", + "category": "negative", + "expect": "reject-decryption", + "file": "negative/cross-container-transplant.pqfe", + "sha256": "c15c4e128878fd5676cedf6267a1e0d287fff264f713da16242b2633e1a31c6f", + "keySource": 1, + "passphrase": "test-vector-passphrase", + "derivedFrom": "passphrase-pbkdf2-multichunk.pqfe", + "notes": "Frame 0 of a second container (same passphrase, same plaintext, different salt and nonce prefix) transplanted into the multi-chunk vector at the same ordinal: the per-encryption key and header-as-AAD separation must reject splicing between containers." + }, { "id": "neg-wrong-passphrase", "category": "negative", diff --git a/test-vectors/negative/argon2-iterations-out-of-range.pqfe b/test-vectors/negative/argon2-iterations-out-of-range.pqfe new file mode 100644 index 0000000000000000000000000000000000000000..fd670965cf99c10daa0215cda6e53b27d6326712 GIT binary patch literal 116 zcmWFtbaQ25WMp7qU}5myHrR~G~;Gx9sT zLb`wU74xg@p*dT(Z|eOwaj*5X*_V61D7-T~8JF*J#@RgOO)TT2ZJUdtr#HRdt>QMP UBub!!MQ_rvllSLy#I0!t0N9x=xc~qF literal 0 HcmV?d00001 diff --git a/test-vectors/negative/argon2-memory-out-of-range.pqfe b/test-vectors/negative/argon2-memory-out-of-range.pqfe new file mode 100644 index 0000000000000000000000000000000000000000..90ed04e47d571e4b7d1b82a53fc709b59b108a4e GIT binary patch literal 116 zcmWFtbaQ25WMp7qU}5myHrW%jPxMxG09$UM9f1sWb9o5lD4yQTbF|G WyCg=ObXpK}1TK=s$={y{UaW)2do6(g literal 0 HcmV?d00001 diff --git a/test-vectors/negative/cross-container-transplant.pqfe b/test-vectors/negative/cross-container-transplant.pqfe new file mode 100644 index 0000000000000000000000000000000000000000..eb8b72d066b56e414823cdb3e051f81b4bbb12b4 GIT binary patch literal 2130 zcmV-Y2(9-}QAR}q0RaF200aO;L_|aY76A}IKtMo1KtMo1KtMo1KtMnM0fwLe000C4 zKr#uSUOeB>^zc1PKqvbq@yicWMVNg}m;~pdoC-H+`cFp|m~uT8LIhjqc=z40rjQQlXq# z2MmTq!Fu2HwS$pNf=1Y<47bkz-rf~zGge0b7)=)z8n3CXi*~X$ERRtir{2_gYik}z zChRC@C;nYT)P%)hfR+Ks9K_xA#{RZ*+mriYi3y8EIM~ix=IhwvggS$juGz&fu7NX= zlIcuuu*h0-gnWS0#oqtPkmzx0_EvB;gXxF5K4>3u(d3%tO@_xE{Kc%pc?O?wVozqJ zXZKxMr*SpTG;&MU`s_xq0Vr!@%m_zY0_mnx+>!p!`k(~)G=FQOY?_mJFBzKb`Hc&K zyU>1TK_IOcOb&VDdLhExKyUV|UTt(A0X~X=5v0!Q|Ere}va*;(^{Vof^;5=-Cl8S@ zytuV4=yHxB{cyV1Ry^b2Vg>q9@5q5p1Ty(0)IwzF!~JvS(DsE`!~^fNq==<>v}wt{ zP*GEt!%~f&dKXpbW_b>$KFHjEbYX>1OPea5Xrs-5f8rw1$C%; zPWUqBWqNd_pn>v0G0{mqAql6QYP-|T` zY$y>^2BYR0cmpMxX2bbctc{PVZHlFJg_b>lJ!BSeoO-sIZ5B-jmAr>s!{6=!h+w5g zJ=A!X-SixoS~SD}TLabLlZ)VtJ zGq1A&T=7wP)v1RRUdgSJawgP%8t==VQ3^TkOo^v8M~9BqNMp+M&UDr(Z*MCd@8;QK zN*&oub0E|Ger){8`_em%Y2GAm^=OQ!YtUG|yZUiEes5&sHf&7cN@Hs6aRbt~z|QIt zAN_zn@0=CI)WHp4>v$*4i0c4>t))AwlK)wcgdALkOYZlUdv_{HxCZUfK+9(eeskzi zu7{vQ{Y=re?4vb#au63ylbs}7OU^$PfiT8S77RcX5a8$6IjnQm?v7YcRQLd(q#3}s z0RR9505&=y?SUgv;Q}~!hU%erd~dgc!z@Ka;l-?NH7h?8jiB;(C9gr40W3r-$-cKW z@{Ex0{%3^%0bGgaGQMsi?&eM)GM^*lF4&@uvPXx;%p3;C zgJ}2VZ9I`okjKjO=H+w5)6`sKk{;YUvGvuXkv5OyH*1XKT0J{RJ2jqc{}752t8E+l z5%gBYNS|r62<~~OJLs#xg2m@tDZEtdpAud{lQqWSc1d*3BAvM~P~`8|dc6T4AFKf) zX!g#}^MO!j3-QYcy(8Avc%PKs)y1c`jbRidlL<~mUQDCvL@9R^z-+dVo6G35cUXF8 z{3=hq;P+_+xthB*RkSt$YO35gA>*0rwIXO<0ta}fO6l#AM($qP2IU>~jDDYZ?Curn zWTVcd?OpJ~T{=&h^ z3GYqe3JX;5+*={o=e}$~#jbx~SnV2cTy|1YOwGB}Qjy`8{vZRw-nr2#XNPzd7 z%m1;dVHMkl*EWSgoX%Pp79twwsJTw=Tpi}!#(tLe=r2q0# zhu0$LAilI=Kp)B+2H!-yDJ8<~xX%10NNV0%nW74)7zTaV0G%tu=HzgFOgUy5pfPSe z+`Af9)iWB9c|zhY&zH~8Id!)IKrdIPg%Y4EysS(Dqm+APZwGoP+uk2TH^iknO@h3M z^azw(z6@kq=U0F?3z|=jB@AF5GvAWs?Lct85?;(*fcU5=3&z~t2HhU;8igKN77}W0 z)s0x%j=+{IEEP@Ynh9tq0th#0Z9gS@Ze*Hwfwg_yWS`;LrCu7 z_BHpW(4CkI7I9tk3_fZ=`HJ2aiJ2c4#n?kLC%N9gF`XXQbeNGGJ>^`3SzsEqPunnKxWj9Kn@irzV}^ z{!&a~o<}Ux{79#j*~VIMnKhy!>E+@@*wJ5hTgK4*6(TG#i&vDUku5_cn&}Qmm(oEpw&&fTBoqsBU9@5`liY I7{JCm7XgJG{Qv*} literal 0 HcmV?d00001 diff --git a/test-vectors/negative/final-frame-dropped.pqfe b/test-vectors/negative/final-frame-dropped.pqfe new file mode 100644 index 0000000000000000000000000000000000000000..e502af11fccde48c1ba089333c16c00994ac0756 GIT binary patch literal 1085 zcmV-D1j74JQAR}q0RaF200aO;L_|aY76A}IKtMo1KtMo1KtMo1KtMnM0fwLe000C4 z@IXE`|4zL&{7-imY=O)#QZ9K@e0n030&@9P-+rHN=_qr=Il<95(J-cVZO8H+)AxWGTD2DsKVmo)cGEUQs?FjVwSzMhBwiXYF&xoAFH6S z=C2TpaSlMW^u5+PdxqP1pd0nHBg%mMHk0B{Y9%^b)6RaTSpK3%6Al6{)KlRNZhLmM zQ=RP;&xV4RX@$k;<}K^Cd%)ib6fy#XJ3=IYupt-g+HoD{l$(JWmC5_+9GW3OKLw ze`3iJ!F`O53hmv_Ug%mNRA+y^2bExM&c(@Q-5OaW&55Q<4iBfpeEaUPDX7mzD7q#k zvw1HM*m?wvCLdxCjM>eOH3lu+M9q>{4OH2PK zrJ3^FQOzt@OkGwVqzI`Spyh`NoT04Hum_SNNq&EP?i|Z0kn2&=7q@;!E-*@N)6OOW z$$MkX>_8fMp4b_+s;%zT^+Q;BBNHr&3NrcXUk%WMV8?hasA6@{V)uZpT9MoN9c8VC zKI$4-AaCdK+jac6Cr*Er%oq~oK|<*v&Y&H#ycsuEDi+gnwVGuCKn;- zH~69WYqg%F{Zb!ta z?FJX$sH*A3h1Un>6;;}lG@x^F_-c z%s+KNt`;-`qh20X97IOK?At{XL^*;m&boMs5U9xDhLuRR5YYMGd|XcCPu@s(ANV8f zoDO%>F?jl2KeH@0BwQjkGUkMAX_+OI4Ry*2 z9dn?IRWCA8^d~wqAeH0ecDuwumFvG2b%W)*c*$aan~ev05L?9pd7_v#qF@2ho%?k?XWK~)*b!kO_E DRwN9R literal 0 HcmV?d00001 diff --git a/test-vectors/negative/frame-swap.pqfe b/test-vectors/negative/frame-swap.pqfe new file mode 100644 index 0000000000000000000000000000000000000000..5adfb051d3e07fb9b7d201cca221e023b0c08224 GIT binary patch literal 2130 zcmV-Y2(9-}QAR}q0RaF200aO;L_|aY76A}IKtMo1KtMo1KtMo1KtMnM0fwLf000C4 zHaa2gfg@4j0yuYu>Y;ahZ?}TOEJZ}&#jI^LD?byBpz?PmuR)jrEJQ2GzPB~)0U+DtDIRyRzc!AAy%Y10 z=yi>zScShn_$%sjU421Xo0tdzT#4s0zHTD!=1w3opCjZh*rJZIM~BAD90tdOX!qr9 zJdsS0$IA5P<#WW-)Ldke9^5;z_0^-1Hjm^tYmDStJv&G{HJ)t$5Q-A3Z5#R#^j5`4 zpJ}uR?s=y>=&Qhj#phfpyj1L;5?(=*HOAp~Np#L4ow+bjVDR&gWY_^b_%jmRsSbAvuDo?%O z_h|&Vn!7bsv^D^0s@ymsFtt6?q1pkk?iK1}qt2!6 zUGTzO>SHhZ@6?%%5Oeci-iTO3YZ!99<^9>aK!okW3^N!c* zpF}h~TL-7vZZjl*g{XEFyBb#2 zGa8V2LgFsZm(S2Sb+-XPFIT695}+%*tV{x@cL)fCMvN$ zE!y(O-63#?br~A}leBSCpoaEkh)l=?+Mj)Y;@E-pq(t zPrz^<^+~0iS_O;-c(mikd5y+CjF4{|f-myYxG)n^tsgcy^-7X9n2ViXyBnbEW%$qDXV7Zex8CfquFez{Wcl z000040PsLQHvdk&HvCU_7i@vdFH$afQ+#?NlmfCK158ut9#w6>%}!D;`bDIAQp#(# zUzOM%C#5q{9j}PsJw&8YVt^jeJ0$<(R#0jZL`qI0dFJd+(XfbmtS=3N*vuTrcI{uA z&0u+C|HmsSs-HeeixLE%aACf)=iExIOETGeg{Z>fchEnI|3}TkOw1zj)k!oFu z;2*1?u;#B2jByS?we-E#I(vrOc%U2gv?I!Z{5F%~PiiGPThq>drda->M-vVLF4R-u z4Q_jOwNst#6y*S+@w%b@S$32OS|p%p6}lkJLc@_%B<62X0pjtcGF&R*zRAXI06y$6+GZqCKYX5AWDB+ZGYOAZgG!+iVhu_>s} zMku-_C9`=i57>GHj3ysq4~*H(jx`1?-A5#iC((TJG1b7&=edB7{yz`Ai>Px^-5u~z z`AbXxD5aV5+)>ReS4>@2AEXGW8=&Qf37ny<(69%RB1wLKeC`~}DUjt0?B)0&FnxLd7juAwW_V|)%8PIc_R}niV8CM>R%1egJ8#aE~sL4&|>$1ty+=W z`5k4ghCb>VSs-uc@!NI$wzbHY z0wxzB=r{PG_-nPHZOS`!On&fK41RQP+mQd{t$NRV$@nOhufOmJp`+9z$?!;eptcii=!SD&}YSd?B*8uz9nb06K z)^10{sqF?A->9nT#f8@g<`q@iu60pgFcT>jIKr-1KH6}8v4p_WQ_E)PIoMUp1LAZB zXA7oo5VO#5XZX<|V+bZ}a!ZMhSJBDVc7{bFN1M3wk zy7NWLAJ|Ga!}Y<955mL6z&j7IlN=yLicBf18a5dJ;dGA7qnKC)e1+ zsQFo7KL-UYh7O|pp^gd`eaeRKJj@-QARAD-vyOUXMgR?7_IIsw{+iQ(?;%XJ7;?Gx9sT zLb`wU74xg@p*dT(Z|eOwaj*5X*_V61D7-T~8JF*J#@RgOO)TT2ZJUdtr#HRdt>QMP UBub!!MQ_rvllSLy#I0!t0L5A@c>n+a literal 0 HcmV?d00001 diff --git a/test-vectors/negative/truncated-at-frame-boundary.pqfe b/test-vectors/negative/truncated-at-frame-boundary.pqfe new file mode 100644 index 0000000000000000000000000000000000000000..d2533102139b55467f096f864ea643cd6309b9b7 GIT binary patch literal 40 wcmWFtbaQ25WMp7qU|~?}Tlk4Vj8P!`%E7GFlB#lkRV}BS{{;6jFt#lK0L(!QT>t<8 literal 0 HcmV?d00001 diff --git a/test-vectors/passphrase-pbkdf2-multichunk.pqfe b/test-vectors/passphrase-pbkdf2-multichunk.pqfe new file mode 100644 index 0000000000000000000000000000000000000000..f2d244094ee2ac355b0e74f49540bc39d75cfd87 GIT binary patch literal 2130 zcmV-Y2(9-}QAR}q0RaF200aO;L_|aY76A}IKtMo1KtMo1KtMo1KtMnM0fwLe000C4 z@IXE`|4zL&{7-imY=O)#QZ9K@e0n030&@9P-+rHN=_qr=Il<95(J-cVZO8H+)AxWGTD2DsKVmo)cGEUQs?FjVwSzMhBwiXYF&xoAFH6S z=C2TpaSlMW^u5+PdxqP1pd0nHBg%mMHk0B{Y9%^b)6RaTSpK3%6Al6{)KlRNZhLmM zQ=RP;&xV4RX@$k;<}K^Cd%)ib6fy#XJ3=IYupt-g+HoD{l$(JWmC5_+9GW3OKLw ze`3iJ!F`O53hmv_Ug%mNRA+y^2bExM&c(@Q-5OaW&55Q<4iBfpeEaUPDX7mzD7q#k zvw1HM*m?wvCLdxCjM>eOH3lu+M9q>{4OH2PK zrJ3^FQOzt@OkGwVqzI`Spyh`NoT04Hum_SNNq&EP?i|Z0kn2&=7q@;!E-*@N)6OOW z$$MkX>_8fMp4b_+s;%zT^+Q;BBNHr&3NrcXUk%WMV8?hasA6@{V)uZpT9MoN9c8VC zKI$4-AaCdK+jac6Cr*Er%oq~oK|<*v&Y&H#ycsuEDi+gnwVGuCKn;- zH~69WYqg%F{Zb!ta z?FJX$sH*A3h1Un>6;;}lG@x^F_-c z%s+KNt`;-`qh20X97IOK?At{XL^*;m&boMs5U9xDhLuRR5YYMGd|XcCPu@s(ANV8f zoDO%>F?jl2KeH@0BwQjkGUkMAX_+OI4Ry*2 z9dn?IRWCA8^d~wqAeH0ecDuwumFvG2b%W)*c*$aan~ev05L?9pd7_v#qF@2ho%?k?XWK~)*b!kO_E z0RR9505&=y?SUgv;Q}~!hU%erd~dgc!z@Ka;l-?NH7h?8jiB;(C9gr40W3r-$-cKW z@{Ex0{%3^%0bGgaGQMsi?&eM)GM^*lF4&@uvPXx;%p3;C zgJ}2VZ9I`okjKjO=H+w5)6`sKk{;YUvGvuXkv5OyH*1XKT0J{RJ2jqc{}752t8E+l z5%gBYNS|r62<~~OJLs#xg2m@tDZEtdpAud{lQqWSc1d*3BAvM~P~`8|dc6T4AFKf) zX!g#}^MO!j3-QYcy(8Avc%PKs)y1c`jbRidlL<~mUQDCvL@9R^z-+dVo6G35cUXF8 z{3=hq;P+_+xthB*RkSt$YO35gA>*0rwIXO<0ta}fO6l#AM($qP2IU>~jDDYZ?Curn zWTVcd?OpJ~T{=&h^ z3GYqe3JX;5+*={o=e}$~#jbx~SnV2cTy|1YOwGB}Qjy`8{vZRw-nr2#XNPzd7 z%m1;dVHMkl*EWSgoX%Pp79twwsJTw=Tpi}!#(tLe=r2q0# zhu0$LAilI=Kp)B+2H!-yDJ8<~xX%10NNV0%nW74)7zTaV0G%tu=HzgFOgUy5pfPSe z+`Af9)iWB9c|zhY&zH~8Id!)IKrdIPg%Y4EysS(Dqm+APZwGoP+uk2TH^iknO@h3M z^azw(z6@kq=U0F?3z|=jB@AF5GvAWs?Lct85?;(*fcU5=3&z~t2HhU;8igKN77}W0 z)s0x%j=+{IEEP@Ynh9tq0th#0Z9gS@Ze*Hwfwg_yWS`;LrCu7 z_BHpW(4CkI7I9tk3_fZ=`HJ2aiJ2c4#n?kLC%N9gF`XXQbeNGGJ>^`3SzsEqPunnKxWj9Kn@irzV}^ z{!&a~o<}Ux{79#j*~VIMnKhy!>E+@@*wJ5hTgK4*6(TG#i&vDUku5_cn&}Qmm(oEpw&&fTBoqsBU9@5`liY I7{JCm7t#O?zW@LL literal 0 HcmV?d00001 diff --git a/tests/PostQuantum.FileEncryption.Analyzers.Tests/PostQuantum.FileEncryption.Analyzers.Tests.csproj b/tests/PostQuantum.FileEncryption.Analyzers.Tests/PostQuantum.FileEncryption.Analyzers.Tests.csproj index 6d0129d..f6c0758 100644 --- a/tests/PostQuantum.FileEncryption.Analyzers.Tests/PostQuantum.FileEncryption.Analyzers.Tests.csproj +++ b/tests/PostQuantum.FileEncryption.Analyzers.Tests/PostQuantum.FileEncryption.Analyzers.Tests.csproj @@ -18,6 +18,9 @@ + + diff --git a/tests/PostQuantum.FileEncryption.Tests/ConformanceVectors.cs b/tests/PostQuantum.FileEncryption.Tests/ConformanceVectors.cs index a9ca581..25d7eef 100644 --- a/tests/PostQuantum.FileEncryption.Tests/ConformanceVectors.cs +++ b/tests/PostQuantum.FileEncryption.Tests/ConformanceVectors.cs @@ -106,6 +106,27 @@ public async Task Regenerate_conformance_corpus() "cross-impl-passphrase", "Encrypted by the Rust/WASM core, decrypted by .NET.", "Frozen KAT Vector 3 (produced by the Rust core).")); + // A deterministic TWO-chunk container (1024-byte chunks): the base for the + // frame-ordering and cross-container negatives below, and a positive pin for + // multi-frame decryption in both implementations. + string multiChunkPlaintext = string.Concat(Enumerable.Repeat("PQFE multi-chunk conformance vector. ", 56))[..2048]; + byte[] multiChunk = await MakePassphraseContainerAsync( + salt: Filled(0x40, 16), iterations: 100_000, noncePrefix: Filled(0x44, 4), + plaintext: Encoding.UTF8.GetBytes(multiChunkPlaintext), flags: 0x00, keyParamsTrailing: null); + vectors.Add(new ConformanceVector + { + Id = "pos-passphrase-pbkdf2-multichunk", + Category = "positive", + Expect = "accept", + File = "passphrase-pbkdf2-multichunk.pqfe", + Sha256 = Write(dir, "passphrase-pbkdf2-multichunk.pqfe", multiChunk), + KeySource = 1, + Passphrase = "test-vector-passphrase", + PlaintextUtf8 = multiChunkPlaintext, + Notes = "Deterministic two-chunk container (1024-byte chunks, PBKDF2 100k): pins " + + "frame ordering, the final-frame marker, and multi-frame decryption cross-implementation.", + }); + // ---- negatives: deterministic single mutations of the frozen PBKDF2 vector ---- byte[] baseVector = await File.ReadAllBytesAsync(Path.Combine(dir, "passphrase-pbkdf2.pqfe")); int headerLen = ContainerFormat.FixedHeaderLength @@ -138,6 +159,70 @@ public async Task Regenerate_conformance_corpus() vectors.Add(Negative("neg-not-a-container", "reject-format", new byte[64], dir, "64 zero bytes: no PQFE magic.")); + // ---- negatives derived from the frozen Argon2id vector: cost/salt bounds ---- + // These pin the CONFORMANCE.md 2.1 rule-5 MUSTs for the Argon2id side — the checks + // that stop a ~90-byte hostile header demanding 2 GiB of memory — in BOTH readers. + byte[] argonBase = await File.ReadAllBytesAsync(Path.Combine(dir, "passphrase-argon2id.pqfe")); + int argonSaltLen = argonBase[ContainerFormat.FixedHeaderLength + 1]; + int argonParams = ContainerFormat.FixedHeaderLength + 2 + argonSaltLen; // MemoryKiB(4) ‖ Iterations(4) ‖ Parallelism(1) + + vectors.Add(Negative("neg-argon2-memory-out-of-range", "reject-format", + Mutate(argonBase, m => BinaryPrimitives.WriteUInt32BigEndian(m.AsSpan(argonParams), 2_097_153)), dir, + "Argon2id memory set to 2,097,153 KiB — one above the format maximum; rejected before any derivation.", + derivedFrom: "passphrase-argon2id.pqfe")); + vectors.Add(Negative("neg-argon2-iterations-out-of-range", "reject-format", + Mutate(argonBase, m => BinaryPrimitives.WriteUInt32BigEndian(m.AsSpan(argonParams + 4), 10_001)), dir, + "Argon2id iterations set to 10,001 — one above the format maximum; rejected before any derivation.", + derivedFrom: "passphrase-argon2id.pqfe")); + vectors.Add(Negative("neg-argon2-parallelism-zero", "reject-format", + Mutate(argonBase, m => m[argonParams + 8] = 0), dir, + "Argon2id parallelism set to 0 — below the minimum of 1; rejected before any derivation.", + derivedFrom: "passphrase-argon2id.pqfe")); + vectors.Add(Negative("neg-salt-too-short", "reject-format", + Mutate(argonBase, m => m[ContainerFormat.FixedHeaderLength + 1] = 7), dir, + "Declared salt length set to 7 — below the 8-byte floor; rejected before any derivation.", + derivedFrom: "passphrase-argon2id.pqfe")); + + // ---- clean-boundary truncation: header only, zero frames ---- + vectors.Add(Negative("neg-truncated-at-frame-boundary", "reject-decryption", + baseVector[..headerLen], dir, + "Container cut exactly at the header/frame boundary: parses cleanly but carries no " + + "authenticated final frame, so a conforming reader must reject it.")); + + // ---- frame-ordering and cross-container negatives from the multi-chunk vector ---- + int mcHeaderLen = ContainerFormat.FixedHeaderLength + + BinaryPrimitives.ReadUInt16BigEndian(multiChunk.AsSpan(ContainerFormat.OffsetKeyParamsLength)); + int mcFrame = 5 + 1024 + ContainerFormat.TagLength; + + byte[] frameSwap = (byte[])multiChunk.Clone(); + Array.Copy(multiChunk, mcHeaderLen + mcFrame, frameSwap, mcHeaderLen, mcFrame); + Array.Copy(multiChunk, mcHeaderLen, frameSwap, mcHeaderLen + mcFrame, mcFrame); + vectors.Add(Negative("neg-frame-swap", "reject-decryption", frameSwap, dir, + "The two frames of the multi-chunk vector swapped on disk: each frame's ordinal is " + + "bound as AAD, so reordering fails authentication.", + derivedFrom: "passphrase-pbkdf2-multichunk.pqfe")); + + vectors.Add(Negative("neg-final-frame-dropped", "reject-decryption", + multiChunk[..(mcHeaderLen + mcFrame)], dir, + "The multi-chunk vector cut cleanly after its first (authentic, non-final) frame: " + + "no authenticated final marker, so a conforming reader must reject it.", + derivedFrom: "passphrase-pbkdf2-multichunk.pqfe")); + + byte[] otherContainer = await MakePassphraseContainerAsync( + salt: Filled(0x50, 16), iterations: 100_000, noncePrefix: Filled(0x55, 4), + plaintext: Encoding.UTF8.GetBytes(multiChunkPlaintext), flags: 0x00, keyParamsTrailing: null); + if (otherContainer.Length != multiChunk.Length) + { + throw new InvalidOperationException("Cross-container bases must be structurally identical."); + } + byte[] transplant = (byte[])multiChunk.Clone(); + Array.Copy(otherContainer, mcHeaderLen, transplant, mcHeaderLen, mcFrame); + vectors.Add(Negative("neg-cross-container-transplant", "reject-decryption", transplant, dir, + "Frame 0 of a second container (same passphrase, same plaintext, different salt and " + + "nonce prefix) transplanted into the multi-chunk vector at the same ordinal: the " + + "per-encryption key and header-as-AAD separation must reject splicing between containers.", + derivedFrom: "passphrase-pbkdf2-multichunk.pqfe")); + // A negative that needs no new file: the frozen vector with the wrong passphrase. var good = vectors[0]; vectors.Add(new ConformanceVector @@ -175,10 +260,25 @@ public async Task Regenerate_conformance_corpus() "test-vector-passphrase", "PostQuantum.FileEncryption known-answer vector v2.", 1, "4 bytes appended after the final frame. Decryption stops at the authenticated final frame. Format-v3 candidate.")); - (byte[] multiContainer, byte[] multiPrivate) = MakeMultiRecipientTrailing( - noncePrefix: Filled(0x33, 4), - plaintext: "PostQuantum.FileEncryption conformance: trailing block past the multi-recipient count."u8.ToArray()); - await File.WriteAllBytesAsync(Path.Combine(dir, "lenient", "multi-recipient-trailing.key"), multiPrivate); + // Generated ONCE and pinned: KEM encapsulation is randomized, so regenerating this + // vector would change frozen committed bytes. Reuse the committed artifacts when they + // exist; only a brand-new corpus (or a deliberate deletion) regenerates them. + string mrContainerPath = Path.Combine(dir, "lenient", "multi-recipient-trailing.pqfe"); + string mrKeyPath = Path.Combine(dir, "lenient", "multi-recipient-trailing.key"); + byte[] multiContainer; + byte[] multiPrivate; + if (File.Exists(mrContainerPath) && File.Exists(mrKeyPath)) + { + multiContainer = await File.ReadAllBytesAsync(mrContainerPath); + multiPrivate = await File.ReadAllBytesAsync(mrKeyPath); + } + else + { + (multiContainer, multiPrivate) = MakeMultiRecipientTrailing( + noncePrefix: Filled(0x33, 4), + plaintext: "PostQuantum.FileEncryption conformance: trailing block past the multi-recipient count."u8.ToArray()); + } + await File.WriteAllBytesAsync(mrKeyPath, multiPrivate); vectors.Add(new ConformanceVector { Id = "lenient-multi-recipient-trailing", @@ -223,7 +323,9 @@ private static ConformanceVector Positive( Notes = notes, }; - private static ConformanceVector Negative(string id, string expect, byte[] bytes, string dir, string notes) + private static ConformanceVector Negative( + string id, string expect, byte[] bytes, string dir, string notes, + string derivedFrom = "passphrase-pbkdf2.pqfe") { string file = "negative/" + id["neg-".Length..] + (id == "neg-not-a-container" ? ".bin" : ".pqfe"); return new ConformanceVector @@ -235,7 +337,7 @@ private static ConformanceVector Negative(string id, string expect, byte[] bytes Sha256 = Write(dir, file, bytes), KeySource = 1, Passphrase = "test-vector-passphrase", - DerivedFrom = "passphrase-pbkdf2.pqfe", + DerivedFrom = derivedFrom, Notes = notes, }; } diff --git a/tests/PostQuantum.FileEncryption.Tests/DecryptionLimitsTests.cs b/tests/PostQuantum.FileEncryption.Tests/DecryptionLimitsTests.cs index 0d45c44..44f018e 100644 --- a/tests/PostQuantum.FileEncryption.Tests/DecryptionLimitsTests.cs +++ b/tests/PostQuantum.FileEncryption.Tests/DecryptionLimitsTests.cs @@ -188,4 +188,63 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } } + + // ---------------------------------------------------------------- Argon2 parallelism ceiling + + [Fact] + public async Task Argon2id_parallelism_above_the_limit_is_rejected_before_derivation() + { + var options = new PqEncryptionOptions + { + Kdf = PqKdf.Argon2id, + Argon2MemoryKiB = 8 * 1024, // format minimum — cheap for the suite + Argon2Iterations = 1, + Argon2Parallelism = 3, + ChunkSizeBytes = 1024, + }; + byte[] container = await EncryptAsync(RandomBytes(64), options); + + var strict = new PqFileDecryptor(new PqDecryptionLimits { MaxArgon2Parallelism = 2 }); + await Assert.ThrowsAsync(() => strict.DecryptBytesAsync(container, Passphrase)); + + // The same container opens fine under the defaults (limit 255 = the format maximum). + byte[] restored = await new PqFileDecryptor().DecryptBytesAsync(container, Passphrase); + Assert.Equal(64, restored.Length); + } + + [Fact] + public async Task Untrusted_preset_caps_argon2id_parallelism_at_8() + { + var atCap = new PqEncryptionOptions + { + Kdf = PqKdf.Argon2id, + Argon2MemoryKiB = 8 * 1024, + Argon2Iterations = 1, + Argon2Parallelism = 8, + ChunkSizeBytes = 1024, + }; + byte[] fine = await EncryptAsync(RandomBytes(32), atCap); + var untrusted = new PqFileDecryptor(PqDecryptionLimits.Untrusted); + Assert.Equal(32, (await untrusted.DecryptBytesAsync(fine, Passphrase)).Length); + + var overCapOptions = new PqEncryptionOptions + { + Kdf = PqKdf.Argon2id, + Argon2MemoryKiB = 8 * 1024, + Argon2Iterations = 1, + Argon2Parallelism = 9, + ChunkSizeBytes = 1024, + }; + byte[] overCap = await EncryptAsync(RandomBytes(32), overCapOptions); + await Assert.ThrowsAsync(() => untrusted.DecryptBytesAsync(overCap, Passphrase)); + } + + [Theory] + [InlineData(0)] + [InlineData(256)] + public void Parallelism_limit_outside_the_byte_range_is_a_configuration_error(int limit) + { + Assert.Throws(() => + new PqFileDecryptor(new PqDecryptionLimits { MaxArgon2Parallelism = limit })); + } } diff --git a/tests/PostQuantum.FileEncryption.Tests/ErrorHandlingTests.cs b/tests/PostQuantum.FileEncryption.Tests/ErrorHandlingTests.cs index 40f1d48..e168c87 100644 --- a/tests/PostQuantum.FileEncryption.Tests/ErrorHandlingTests.cs +++ b/tests/PostQuantum.FileEncryption.Tests/ErrorHandlingTests.cs @@ -70,6 +70,29 @@ await Assert.ThrowsAsync(() => new PqFileDecryptor().DecryptAsync(new MemoryStream(swapped), output, Passphrase)); } + [Fact] + public async Task Cross_container_chunk_transplant_is_rejected() + { + // SECURITY.md claims the AAD defeats "splicing between containers": an authentic frame + // from a second container encrypted under the SAME passphrase must not transplant into + // the first at the same ordinal. The defense rests on the per-encryption salt and nonce + // prefix (fresh key + different header-as-AAD), which is exactly what this pins. + byte[] original = RandomBytes(2048); + var encryptor = new PqFileEncryptor(Fast(1024)); + byte[] containerA = await encryptor.EncryptBytesAsync(original, Passphrase); + byte[] containerB = await encryptor.EncryptBytesAsync(original, Passphrase); + + int HeaderLen(byte[] c) => 18 + BinaryPrimitives.ReadUInt16BigEndian(c.AsSpan(16, 2)); + int frameLen = 1 + 4 + 1024 + 16; + + var transplanted = (byte[])containerA.Clone(); + Array.Copy(containerB, HeaderLen(containerB), transplanted, HeaderLen(containerA), frameLen); + + using var output = new MemoryStream(); + await Assert.ThrowsAsync(() => + new PqFileDecryptor().DecryptAsync(new MemoryStream(transplanted), output, Passphrase)); + } + [Fact] public async Task Unsupported_format_version_is_a_format_error() { diff --git a/tests/PostQuantum.FileEncryption.Tests/HybridTests.cs b/tests/PostQuantum.FileEncryption.Tests/HybridTests.cs index 1f0ace3..1643b86 100644 --- a/tests/PostQuantum.FileEncryption.Tests/HybridTests.cs +++ b/tests/PostQuantum.FileEncryption.Tests/HybridTests.cs @@ -1,3 +1,4 @@ +using System.Buffers.Binary; using PostQuantum.FileEncryption.Hybrid; using Xunit; using static PostQuantum.FileEncryption.Tests.TestSupport; @@ -343,4 +344,97 @@ public async Task Core_passphrase_decryptor_rejects_a_hybrid_container() await Assert.ThrowsAsync(() => new PqFileDecryptor().DecryptBytesAsync(container, "any passphrase")); } + + // ---- KeySource-4 recipient-block manipulation (HYBRID-COMBINER.md audit pointer) ---- + // + // The whole serialized header — including every recipient block — is bound as AAD into + // every chunk, so swapping, stripping, or duplicating blocks must fail authentication even + // when the manipulated body still parses and a remaining block unwraps for the caller's key. + + private static (int keyParamsOffset, int[] entryOffsets, int entryLength) ParseRecipientEntries(byte[] container) + { + const int keyParamsOffset = 18; + int count = container[keyParamsOffset]; + int[] offsets = new int[count]; + int cursor = keyParamsOffset + 1; + int entryLength = 0; + for (int i = 0; i < count; i++) + { + offsets[i] = cursor; + int blockLength = (container[cursor + 1] << 8) | container[cursor + 2]; + entryLength = 3 + blockLength; // mode(1) + length(2) + block + cursor += entryLength; + } + return (keyParamsOffset, offsets, entryLength); + } + + [Fact] + public async Task Swapping_two_recipient_blocks_is_rejected() + { + using var alice = PqHybridKeyPair.Generate(); + using var bob = PqHybridKeyPair.Generate(); + byte[] container = await new PqHybridEncryptor(Fast()).EncryptBytesToAsync( + RandomBytes(500), [alice.PublicKey, bob.PublicKey]); + + (_, int[] entries, int entryLen) = ParseRecipientEntries(container); + var swapped = (byte[])container.Clone(); + Array.Copy(container, entries[1], swapped, entries[0], entryLen); + Array.Copy(container, entries[0], swapped, entries[1], entryLen); + + await Assert.ThrowsAsync(() => + new PqHybridDecryptor().DecryptBytesAsync(swapped, alice.PrivateKey)); + await Assert.ThrowsAsync(() => + new PqHybridDecryptor().DecryptBytesAsync(swapped, bob.PrivateKey)); + } + + [Fact] + public async Task Stripping_a_recipient_block_is_rejected_for_every_party() + { + using var alice = PqHybridKeyPair.Generate(); + using var bob = PqHybridKeyPair.Generate(); + using var carol = PqHybridKeyPair.Generate(); + byte[] container = await new PqHybridEncryptor(Fast()).EncryptBytesToAsync( + RandomBytes(500), [alice.PublicKey, bob.PublicKey, carol.PublicKey]); + + (int kpOffset, int[] entries, int entryLen) = ParseRecipientEntries(container); + + // Remove carol's (last) block, decrement the count, and shrink KeyParamsLength so the + // container still parses cleanly — the only thing wrong with it is the header change. + var stripped = new byte[container.Length - entryLen]; + Array.Copy(container, 0, stripped, 0, entries[2]); + Array.Copy(container, entries[2] + entryLen, stripped, entries[2], container.Length - entries[2] - entryLen); + stripped[kpOffset]--; // recipient count + ushort kpLen = BinaryPrimitives.ReadUInt16BigEndian(stripped.AsSpan(16, 2)); + BinaryPrimitives.WriteUInt16BigEndian(stripped.AsSpan(16, 2), (ushort)(kpLen - entryLen)); + + // A surviving recipient unwraps the CEK fine — and must still be rejected by the AAD. + await Assert.ThrowsAsync(() => + new PqHybridDecryptor().DecryptBytesAsync(stripped, alice.PrivateKey)); + // The stripped recipient has no block at all: fail-closed the same way. + await Assert.ThrowsAsync(() => + new PqHybridDecryptor().DecryptBytesAsync(stripped, carol.PrivateKey)); + } + + [Fact] + public async Task Duplicating_a_recipient_block_is_rejected() + { + using var alice = PqHybridKeyPair.Generate(); + using var bob = PqHybridKeyPair.Generate(); + byte[] container = await new PqHybridEncryptor(Fast()).EncryptBytesToAsync( + RandomBytes(500), [alice.PublicKey, bob.PublicKey]); + + (int kpOffset, int[] entries, int entryLen) = ParseRecipientEntries(container); + int insertAt = entries[1] + entryLen; // end of the recipient entries + + var duplicated = new byte[container.Length + entryLen]; + Array.Copy(container, 0, duplicated, 0, insertAt); + Array.Copy(container, entries[0], duplicated, insertAt, entryLen); // alice's block again + Array.Copy(container, insertAt, duplicated, insertAt + entryLen, container.Length - insertAt); + duplicated[kpOffset]++; // recipient count + ushort kpLen = BinaryPrimitives.ReadUInt16BigEndian(duplicated.AsSpan(16, 2)); + BinaryPrimitives.WriteUInt16BigEndian(duplicated.AsSpan(16, 2), (ushort)(kpLen + entryLen)); + + await Assert.ThrowsAsync(() => + new PqHybridDecryptor().DecryptBytesAsync(duplicated, alice.PrivateKey)); + } } diff --git a/tests/PostQuantum.FileEncryption.Tests/VectorArtifactTests.cs b/tests/PostQuantum.FileEncryption.Tests/VectorArtifactTests.cs index 35ca4ce..2b902b0 100644 --- a/tests/PostQuantum.FileEncryption.Tests/VectorArtifactTests.cs +++ b/tests/PostQuantum.FileEncryption.Tests/VectorArtifactTests.cs @@ -15,6 +15,7 @@ public sealed class VectorArtifactTests [InlineData("passphrase-pbkdf2.pqfe", "AB32CC1D2F5F673D77D80FC2F45307ABE4A33A35552F2B5C677A9C5818718547")] [InlineData("passphrase-argon2id.pqfe", "4E165D1238FCAD436BAD8B7CD72072B9196E4492AADDFCDDFBC82029F0ECA4EE")] [InlineData("passphrase-pbkdf2-rustcore.pqfe", "B428F6492C78FE03B8B3197872E60BD737764BE066CDADDAB594F06F18E6ADE6")] + [InlineData("passphrase-pbkdf2-multichunk.pqfe", "008543AB899A427A33100F1B865B54C5EF0426834B8E7B696EEFFC9B97EA886A")] [InlineData("keyfile.pqkf", "EEDA08E328B028E69F87145642C7898C72BE83E410EBFD595F0B2B50FD9BFB38")] [InlineData("hybrid-recipient.pqfe", "A16FF8DB3DAD6A50D9A81CEE5A97CE26D875C8DCE80A00C93DD7516F080D31DE")] public void Committed_vector_artifact_is_byte_identical(string fileName, string expectedSha256) @@ -25,6 +26,45 @@ public void Committed_vector_artifact_is_byte_identical(string fileName, string Assert.Equal(expectedSha256, Convert.ToHexString(SHA256.HashData(bytes))); } + [Fact] + public void Sha256sums_file_matches_the_committed_artifacts_and_lists_them_all() + { + // test-vectors/SHA256SUMS exists so third-party implementers can verify their copy of + // the corpus; until this test it was itself verified by nothing and could silently + // drift from the artifacts it describes. + string dir = Path.Combine(FindRepositoryRoot(), "test-vectors"); + var listed = new Dictionary(StringComparer.Ordinal); + + foreach (string line in File.ReadAllLines(Path.Combine(dir, "SHA256SUMS"))) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + // " *" — the sha256sum binary-mode format. + int sep = line.IndexOf(" *", StringComparison.Ordinal); + Assert.True(sep == 64, $"Unparseable SHA256SUMS line: '{line}'"); + listed[line[(sep + 2)..]] = line[..sep]; + } + + foreach ((string file, string expected) in listed) + { + byte[] bytes = File.ReadAllBytes(Path.Combine(dir, file)); + Assert.Equal(expected, Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant()); + } + + // Every top-level committed artifact must be listed (negatives/lenients are pinned by + // manifest.json instead). + foreach (string path in Directory.EnumerateFiles(dir)) + { + string name = Path.GetFileName(path); + if (name.EndsWith(".pqfe", StringComparison.Ordinal) || name.EndsWith(".pqkf", StringComparison.Ordinal)) + { + Assert.True(listed.ContainsKey(name), $"'{name}' is missing from SHA256SUMS"); + } + } + } + private static string FindRepositoryRoot() { var dir = new DirectoryInfo(AppContext.BaseDirectory); diff --git a/tests/PostQuantum.FileEncryption.Tests/ZeroizationTests.cs b/tests/PostQuantum.FileEncryption.Tests/ZeroizationTests.cs new file mode 100644 index 0000000..ece56dd --- /dev/null +++ b/tests/PostQuantum.FileEncryption.Tests/ZeroizationTests.cs @@ -0,0 +1,117 @@ +using System.Reflection; +using PostQuantum.FileEncryption.Internal; +using Xunit; +using static PostQuantum.FileEncryption.Tests.TestSupport; + +namespace PostQuantum.FileEncryption.Tests; + +/// +/// Regression pins for the key-zeroization contract (THREAT-MODEL.md "Key hygiene"): the +/// engine's finally blocks and the key types' Dispose implementations must leave +/// key buffers all-zero on the success, failure, and cancellation paths. This class of defense +/// has silently regressed before (docs/audits/2026-06-12, findings 1 and 4), and nothing else +/// in the suite would notice — a decryptor works just as well with an un-zeroed key. +/// +public sealed class ZeroizationTests +{ + private static (byte[] key, ContainerHeader header) FreshKeyAndHeader() + { + byte[] key = RandomBytes(ContainerFormat.KeyLength); + var header = ContainerHeader.Create( + ContainerFormat.KeySourcePassphrase, 1024, keyParams: [0x01, 0x02, 0x03]); + return (key, header); + } + + private static void AssertZeroed(byte[] buffer) => + Assert.All(buffer, b => Assert.Equal(0, b)); + + [Fact] + public async Task Engine_zeroes_the_content_key_on_encrypt_and_decrypt_success() + { + (byte[] key, ContainerHeader header) = FreshKeyAndHeader(); + byte[] decryptKey = (byte[])key.Clone(); + byte[] plaintext = RandomBytes(3000); // three frames at the 1024-byte chunk size + + using var container = new MemoryStream(); + await PqContainerEngine.EncryptCoreAsync( + new MemoryStream(plaintext), container, key, header, plaintext.Length, null, default); + AssertZeroed(key); + + container.Position = 0; + ContainerHeader parsed = await PqContainerEngine.ReadHeaderAsync(container, default); + using var output = new MemoryStream(); + await PqContainerEngine.DecryptCoreAsync( + container, output, decryptKey, parsed, container.Length, null, default); + + AssertZeroed(decryptKey); + Assert.Equal(plaintext, output.ToArray()); + } + + [Fact] + public async Task Engine_zeroes_the_content_key_when_decryption_fails_authentication() + { + (byte[] key, ContainerHeader header) = FreshKeyAndHeader(); + byte[] decryptKey = (byte[])key.Clone(); + byte[] plaintext = RandomBytes(500); + + using var container = new MemoryStream(); + await PqContainerEngine.EncryptCoreAsync( + new MemoryStream(plaintext), container, key, header, plaintext.Length, null, default); + + byte[] tampered = container.ToArray(); + tampered[^1] ^= 0x01; // flip a tag byte + + using var source = new MemoryStream(tampered); + ContainerHeader parsed = await PqContainerEngine.ReadHeaderAsync(source, default); + using var output = new MemoryStream(); + await Assert.ThrowsAsync(() => + PqContainerEngine.DecryptCoreAsync(source, output, decryptKey, parsed, tampered.Length, null, default)); + + AssertZeroed(decryptKey); + } + + [Fact] + public async Task Engine_zeroes_the_content_key_when_encryption_is_cancelled() + { + (byte[] key, ContainerHeader header) = FreshKeyAndHeader(); + + using var destination = new MemoryStream(); + await Assert.ThrowsAnyAsync(() => + PqContainerEngine.EncryptCoreAsync( + new MemoryStream(RandomBytes(5000)), destination, key, header, + 5000, null, new CancellationToken(canceled: true))); + + AssertZeroed(key); + } + + [Fact] + public void Recipient_private_key_dispose_zeroes_the_decapsulation_key() + { + byte[] raw = RandomBytes(KemSizes.MlKem768DecapsulationKey); + var key = PqRecipientPrivateKey.Import(raw); + + FieldInfo? field = typeof(PqRecipientPrivateKey) + .GetField("_decapsulationKey", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(field); // renamed field ⇒ update this pin, don't delete it + byte[] held = Assert.IsType(field.GetValue(key)); + Assert.Equal(raw, held); // sanity: we are looking at the real key buffer + + key.Dispose(); + AssertZeroed(held); + } + + [Fact] + public void Local_kek_provider_dispose_zeroes_the_kek() + { + var provider = LocalKekContentKeyProvider.Generate(); + + FieldInfo? field = typeof(LocalKekContentKeyProvider) + .GetField("_kek", BindingFlags.NonPublic | BindingFlags.Instance); + Assert.NotNull(field); // renamed field ⇒ update this pin, don't delete it + byte[] held = Assert.IsType(field.GetValue(provider)); + Assert.Contains(held, b => b != 0); // sanity: a real random KEK + + provider.Dispose(); + AssertZeroed(held); + } +} From 683ff16ad9e09a13a7c8e33ee70fe073641051bd Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Fri, 4 Sep 2026 07:38:33 -0400 Subject: [PATCH 3/3] fix: 16 hunt-confirmed bugs, stage-uniform errors, the KS2 vector, and safer tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Everything here traces to an adversarially verified finding (27-agent bug hunt, each bug independently re-traced before fixing) or to a doubly-recommended item from the external reviews. No on-disk byte meaning or reader acceptance changes. Key hygiene: engine chunk staging buffers zeroed alongside the content key (for PQKF traffic they held a full private key on the heap after export/import); LocalKek ctor zeroes its cloned KEK if AesGcm construction throws. Exception contract: empty byte-passphrase vs an Argon2id header no longer escapes as Konscious's raw ArgumentException; PqKeyFileFormat no longer mislabels PlatformNotSupportedException as wrong-key-type; bytes-API capacity hints no longer overflow int near Array.MaxLength (core + Hybrid); oversized provider wrapInfo gets its intended message. Data-loss guards: PqSigner and pqfe sign refuse signature==input/key (silently destroyed the signed file or private key, exit 0); WebUpload Keygen opens CreateNew (an overwrite orphaned every prior upload), writes private-first with 0600, cleans up half-pairs; CLI overwrite refusal claims the path atomically across the passphrase prompt; second Ctrl+C always terminates. Azure: null-KeyId clients no longer reject everything with a nonsense pin (nor accept '/'-prefixed hostile ids via null+"/" wildcard); recorded key id is control-character-sanitized before reaching exception text. Rust: encrypt_bytes_with validates parameters (chunk 0 looped forever, bad nonce-prefix length panicked, long salt silently made undecryptable output); new decrypt_hybrid fuzz target reaches the KS3/4 block parsers with the pinned conformance key, wired into CI. Stage-uniform errors (behavior change): every in-library key-dependent failure now throws one identical message with no inner exception — wrong passphrase, wrong hybrid/inline recipient key, wrong KEK, tampered wrap, tampered body are indistinguishable across stages. The old distinct unwrap messages let a service exposing raw errors act as a key-possession oracle (externally probed). Pinned by a new cross-stage test; cloud-KMS providers documented as residual. Vector 9: the inline ML-KEM-768 recipient path gets its first pinned vector (decrypt-only, generated + round-trip-verified on Linux/OpenSSL 3.5), closing the last unpinned key-establishment path; hash-pinned on every host, executed wherever platform ML-KEM exists (verified 40/40 on Linux, first known run). Tooling: pqfe decrypt --untrusted; Blazor demo decrypts under Untrusted limits; Backup sample survives locked files; pqfe-web no longer hangs if the worker crashes; docs link checker handles titles and %XX targets. Verified: dotnet 317x2+9+3 green (macOS), 40/40 targeted on Linux (ML-KEM live), cargo 26 tests + both fuzz targets build, docs guard green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Sym7RJ7ehNhbMXytE5rMmS --- .github/workflows/fuzz.yml | 11 +- CHANGELOG.md | 60 +++++++ KNOWN-GAPS.md | 34 ++-- SECURITY.md | 12 +- docs/FUZZING.md | 6 + docs/TEST-VECTORS.md | 23 +++ .../Components/Pages/Home.razor | 5 +- samples/Pqfe.Cli/Program.cs | 157 +++++++++++++++--- samples/pqfe-wasm/fuzz/Cargo.toml | 7 + .../fuzz/fuzz_targets/decrypt_hybrid.rs | 17 ++ .../fuzz/seed-corpus/hybrid-recipient.pqfe | Bin 0 -> 1308 bytes .../seed-corpus/multi-recipient-trailing.pqfe | Bin 0 -> 2498 bytes samples/pqfe-wasm/src/lib.rs | 22 +++ samples/pqfe-web/app.js | 10 +- .../Pqfe.QuickStart.Backup/Program.cs | 7 + .../Program.cs | 43 ++++- scripts/check-docs-consistency.sh | 2 +- .../AzureKeyVaultContentKeyProvider.cs | 40 ++++- .../Internal/HybridKeyEstablishment.cs | 8 +- .../PqHybridEncryptor.cs | 4 +- .../PqSigner.cs | 11 ++ .../Internal/KeyEstablishment.cs | 19 ++- .../Internal/PqContainer.cs | 5 +- .../Internal/PqContainerEngine.cs | 23 ++- .../Internal/PqKeyFileFormat.cs | 2 +- .../LocalKekContentKeyProvider.cs | 18 +- .../PqFileEncryptor.cs | 8 +- test-vectors/SHA256SUMS | 2 + test-vectors/mlkem-recipient.key | Bin 0 -> 2400 bytes test-vectors/mlkem-recipient.pqfe | Bin 0 -> 1265 bytes .../HybridTests.cs | 24 +++ .../RecipientKnownAnswerVectorTests.cs | 79 +++++++++ .../VectorArtifactTests.cs | 2 + 33 files changed, 587 insertions(+), 74 deletions(-) create mode 100644 samples/pqfe-wasm/fuzz/fuzz_targets/decrypt_hybrid.rs create mode 100644 samples/pqfe-wasm/fuzz/seed-corpus/hybrid-recipient.pqfe create mode 100644 samples/pqfe-wasm/fuzz/seed-corpus/multi-recipient-trailing.pqfe create mode 100644 test-vectors/mlkem-recipient.key create mode 100644 test-vectors/mlkem-recipient.pqfe create mode 100644 tests/PostQuantum.FileEncryption.Tests/RecipientKnownAnswerVectorTests.cs diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 9c1d54b..e0b7375 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -44,8 +44,9 @@ jobs: # corpus (fuzz/corpus, cached above) is gitignored. -n keeps any # coverage-accumulated inputs restored from cache. run: | - mkdir -p fuzz/corpus/decrypt + mkdir -p fuzz/corpus/decrypt fuzz/corpus/decrypt_hybrid cp -n fuzz/seed-corpus/*.pqfe fuzz/corpus/decrypt/ 2>/dev/null || true + cp -n fuzz/seed-corpus/*.pqfe fuzz/corpus/decrypt_hybrid/ 2>/dev/null || true - name: Fuzz the decrypt parser working-directory: samples/pqfe-wasm @@ -53,6 +54,14 @@ jobs: DURATION="${{ github.event.inputs.duration_seconds || '300' }}" cargo +nightly fuzz run decrypt -- -max_total_time="$DURATION" -rss_limit_mb=4096 -timeout=20 + - name: Fuzz the hybrid decrypt parser + working-directory: samples/pqfe-wasm + # The recipient-block parsers (KeySource 3/4) are unreachable from the passphrase + # target; this one drives decrypt_bytes_hybrid with the pinned conformance key. + run: | + DURATION="${{ github.event.inputs.duration_seconds || '300' }}" + cargo +nightly fuzz run decrypt_hybrid -- -max_total_time="$DURATION" -rss_limit_mb=4096 -timeout=20 + - name: Upload crash artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index fc3182f..25fa720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,68 @@ and the `.pqfe` v2 container format is frozen for the entire `1.x` line. ## [Unreleased] +### Fixed + +Sixteen defects found by an adversarially verified multi-agent bug hunt (every finding +independently re-traced before fixing; none affects any on-disk byte or reader acceptance): + +- **Key hygiene:** the container engine's chunk staging buffers are now zeroed in the same + `finally` as the content key — for PQKF traffic those buffers held a full private key on + the managed heap after export/import. `LocalKekContentKeyProvider`'s constructor zeroes its + cloned KEK if `AesGcm` construction throws (the half-built object could never be disposed). +- **Fail-closed exception contract:** an empty byte-passphrase against an Argon2id container + no longer escapes as Konscious's raw `ArgumentException` (the exception family was steered + by an unauthenticated header byte); `PqKeyFileFormat.Decrypt` no longer converts + `PlatformNotSupportedException` into "wrong kind of key file" on AES-GCM-less platforms; + near-`Array.MaxLength` inputs to the bytes APIs no longer throw a raw + `ArgumentOutOfRangeException` from an internal capacity hint (core + Hybrid); an oversized + provider `wrapInfo` is rejected with the intended message instead of an internal-parameter + `ArgumentException`. +- **Data-loss guards:** `PqSigner.SignFileAsync` (and `pqfe sign --signature`) refuse a + signature path equal to the input or key file — previously that silently replaced the + signed file (or the private key) with the signature and reported success; the WebUpload + Keygen sample opens both outputs `CreateNew` (an existing key pair is a hard error — an + overwrite would permanently orphan every prior upload), writes the private half first with + `0600` on Unix, and cleans up on partial failure; the CLI's overwrite refusal now claims + the output path atomically so it holds across the interactive passphrase prompt; a second + Ctrl+C now always terminates the CLI even when a redirected-stdin read cannot observe + cancellation. +- **Azure provider:** a null-`KeyId` (local `JsonWebKey`) client no longer rejects every + legitimate container with a nonsense pin message — nor accepts a hostile `/`-prefixed + recorded id via accidental wildcard (`null + "/"`); the header-derived recorded key id is + control-character-sanitized before it reaches an exception message. +- **Rust core:** `encrypt_bytes_with` validates its parameters (a zero `chunk_size` looped + forever; a wrong-length nonce prefix panicked; an over-long salt silently produced a + container no reader can decrypt); a new `decrypt_hybrid` fuzz target covers the + KeySource-3/4 recipient-block parsers the passphrase target cannot reach. +- **Samples/tooling:** the Backup quickstart no longer aborts the whole run with a raw stack + trace on a locked/unreadable file; the pqfe-web page no longer hangs at "Encrypting…" + forever if the worker crashes mid-operation; the docs-consistency link checker handles + markdown link titles and URL-encoded targets. + +### Changed (behavior) + +- **All in-library key-dependent decryption failures are now fully uniform** — one exception + type, one identical message, no inner exception — across *stages* as well as causes: wrong + passphrase, wrong hybrid/inline-ML-KEM recipient key, wrong local KEK, tampered wrap, and + tampered body are indistinguishable to a caller. Previously the unwrap stage carried + distinct messages ("this file is not encrypted to this key…"), which let a service exposing + raw errors act as a key-possession oracle (externally probed and confirmed). Callers who + matched on the old message text must match on `PqDecryptionException` instead. Cloud-KMS + providers keep their uniform provider-specific operational messages (see KNOWN-GAPS.md). +- **`pqfe decrypt` gains `--untrusted`** (maps to `PqDecryptionLimits.Untrusted`), and the + Blazor demo decrypts anonymous uploads under `Untrusted` limits — the library's own + documented anti-pattern, now fixed in its own samples. + ### Added +- **Vector 9 — inline ML-KEM-768 recipient known-answer vector** (`test-vectors/ + mlkem-recipient.pqfe` + `.key`, decrypt-only, hash-pinned everywhere): the third + key-establishment path, previously pinned by nothing because its round-trip tests self-skip + without platform ML-KEM. Generated and round-trip-verified on a Linux host with OpenSSL 3.5, + where the full suite (RecipientTests included) also ran green — their first known execution. +- **Cross-stage no-oracle test** pinning that a wrong recipient key and a tampered body yield + byte-identical `PqDecryptionException`s with null inner exceptions. - **Nine new conformance-corpus vectors, run by both implementations.** A deterministic two-chunk positive (`pos-passphrase-pbkdf2-multichunk`) plus eight negatives: the Argon2id cost/salt bounds (`neg-argon2-memory-out-of-range`, `neg-argon2-iterations-out-of-range`, diff --git a/KNOWN-GAPS.md b/KNOWN-GAPS.md index 6717a0b..14a2b91 100644 --- a/KNOWN-GAPS.md +++ b/KNOWN-GAPS.md @@ -122,19 +122,18 @@ Last reviewed against: **`1.7.1`**. See [ROADMAP.md](ROADMAP.md) for the forward - **Passphrases are still `string` on the convenience overloads.** The zeroable byte overloads exist, but the `string` overloads remain for ergonomics and cannot zero the caller's `string`. -- **The failure *stage* is distinguishable across key-establishment and body authentication.** - Within each stage messages are uniform and pinned by tests (wrong passphrase vs. tampered - bytes is one identical message), but the stages differ: a hybrid/provider key that fails to - *unwrap* the content key throws "not encrypted to this key…" while a successfully unwrapped - container whose *body* fails authentication throws the generic tamper message (with a - different inner exception). An external probe confirmed the two are programmatically - distinguishable. That tells an attacker submitting a crafted container to a service whether - the service's key could open it — a key-possession/membership signal, though never a - plaintext or key-recovery oracle. Unifying the public message and inner-exception policy - across stages is `1.x`-safe (exception text is not part of the frozen format) but changes - observable behavior callers may match on, so it is deliberately a considered decision rather - than a quiet patch; until then, services should log raw exception detail privately and - return a uniform error to untrusted callers. +- **In-library key-dependent failures are now stage-uniform; cloud-provider unwrap errors are + not.** Every key-dependent failure raised by the library itself — wrong passphrase, wrong + hybrid recipient key (KeySource 3/4), wrong inline ML-KEM key (KeySource 2), wrong local + KEK, tampered wrap, tampered body — throws `PqDecryptionException` with one identical + message and **no inner exception**, so which *stage* rejected the input is not observable + (previously, unwrap-stage failures carried distinct messages — a key-possession signal for + services exposing raw errors; an external probe confirmed it, and it was unified in + response). The residual: the Aws/AzureKeyVault/Gcp providers surface their own uniform but + provider-specific messages for *remote* unwrap failures, so a KeySource-5 deployment using a + cloud KMS still distinguishes "KMS refused the wrap" from "body failed authentication". + Services in that position should return a uniform error to untrusted callers and keep raw + detail in private logs. ### Dependency assurance @@ -315,8 +314,13 @@ Last reviewed against: **`1.7.1`**. See [ROADMAP.md](ROADMAP.md) for the forward executions) and scheduled nightly in CI with a cached corpus (`.github/workflows/fuzz.yml`). OSS-Fuzz integration files are ready (`oss-fuzz/`) but upstream onboarding is not yet done, and the accumulated corpora are still small. See [docs/FUZZING.md](docs/FUZZING.md). -- **Recipient round-trip is not exercised on this CI host**, which lacks platform ML-KEM; those - tests self-skip there. The capability gating *is* tested everywhere. +- **Recipient round-trip tests self-skip on hosts without platform ML-KEM** (including the + current CI runners). The path is now pinned wherever it *can* run: a committed decrypt-only + known-answer vector (`test-vectors/mlkem-recipient.pqfe`, Vector 9 in + [docs/TEST-VECTORS.md](docs/TEST-VECTORS.md)) exercises the frozen KeySource-2 bytes on any + host where `PqKeyPair.IsSupported`, its artifact hashes are pinned on every host, and the + full suite (RecipientTests included) has run green on a Linux host with OpenSSL 3.5. The + capability gating *is* tested everywhere. - **NuGet author-signing** requires a code-signing certificate (not configured); nuget.org applies repository signatures on publish. The release workflow produces an SBOM and a provenance attestation. - **Two target frameworks.** `net8.0` and `net10.0`. The public API is identical on both, with diff --git a/SECURITY.md b/SECURITY.md index 61baf6b..a8c521e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -55,13 +55,13 @@ report until a fix is available and coordinated. `PqDecryptionException`, and every *key-dependent* failure (wrong passphrase vs. tampered ciphertext) carries one identical message — the library never distinguishes "wrong key" from "tampered data" at the public surface, and tests pin those messages byte-identical. + This holds across *stages* as well: a recipient/KEK unwrap failure and a body-authentication + failure throw the identical message with no distinguishing inner exception, so a service + exposing raw error text does not reveal whether its key could unwrap a submitted container. Structural failures that an attacker can already compute from the ciphertext alone - (truncation, corrupt framing) carry distinct but key-independent diagnostics. One - distinction *is* key-dependent: which stage failed. A recipient/provider key that cannot - unwrap the content key reports differently from a body that fails chunk authentication, so - a service that exposes raw error details can reveal whether its key could open a submitted - container — see [KNOWN-GAPS.md](KNOWN-GAPS.md) before surfacing exception text across a - trust boundary. + (truncation, corrupt framing) carry distinct but key-independent diagnostics, and cloud-KMS + providers surface their own provider-specific operational errors — see + [KNOWN-GAPS.md](KNOWN-GAPS.md) before surfacing exception text across a trust boundary. - **No partial output on failure.** File APIs stage every byte to a sibling temp file and only `File.Move` it into place on full success. Stream callers can opt into the same all-or-nothing guarantee via `DecryptAtomicAsync`. diff --git a/docs/FUZZING.md b/docs/FUZZING.md index 3347d81..8c694eb 100644 --- a/docs/FUZZING.md +++ b/docs/FUZZING.md @@ -88,6 +88,12 @@ mkdir -p corpus && cp fuzz/PostQuantum.FileEncryption.Fuzz/seed-corpus/*.pqfe \ > `ParserBoundaryTests` and the committed negative vectors instead. The CI jobs also pass > `-timeout=20` per input and upload `timeout-*`/`oom-*` reproducers alongside `crash-*`. +The Rust side has **two** targets: `decrypt` (passphrase parser) and `decrypt_hybrid`, which +drives `decrypt_bytes_hybrid` with the pinned multi-recipient conformance key so the +KeySource-3/4 recipient-block parsers — unreachable from the passphrase target — get +coverage-guided fuzzing too, including the unwrap-success branch (its matching container is +seeded into the corpus). + ## Scheduled CI `.github/workflows/fuzz.yml` runs both targets nightly (and on demand via *Run workflow*), caches diff --git a/docs/TEST-VECTORS.md b/docs/TEST-VECTORS.md index de25936..dd9693b 100644 --- a/docs/TEST-VECTORS.md +++ b/docs/TEST-VECTORS.md @@ -216,6 +216,29 @@ the two test suites above (`PrivateKeyVector` / `ContainerVector` and `HYBRID8_P --- +## Vector 9 — inline ML-KEM-768 recipient (KeySource 2, deprecated, decrypt-only) + +Pins the deprecated inline recipient path byte-exactly: the KeySource-2 wrap layout +(`KemId ‖ C ‖ KemCiphertext ‖ WrapNonce ‖ WrapTag ‖ WrappedKey`), the ML-KEM-768 +decapsulation, the `HKDF-SHA256` KEK derivation, and the AES-256-GCM key unwrap — the one +key-establishment path no other vector covered (its randomized round-trip tests self-skip on +hosts without platform ML-KEM). Encryption is randomized, so the vector is decrypt-only. +Generated once on a Linux host with OpenSSL 3.5 (platform ML-KEM) and frozen. The artifacts +are committed files rather than inline Base64, hash-pinned by `VectorArtifactTests` and +`test-vectors/SHA256SUMS`; the decrypt is exercised by +`tests/.../RecipientKnownAnswerVectorTests.cs` wherever `PqKeyPair.IsSupported`. The key pair +was generated solely for this vector and protects nothing. The Rust core does not implement +this mode, so the vector is not in the cross-implementation manifest. + +| Field | Value | +| --- | --- | +| Key source | `2` (inline ML-KEM-768 recipient, deprecated `PQFE001`/`PQFE002`) | +| Container | [`test-vectors/mlkem-recipient.pqfe`](../test-vectors/mlkem-recipient.pqfe), SHA-256 `02d3614753172b9eb9690cb35325794fac5e9a67faf5f81377b708725ed00503` | +| Recipient private key | [`test-vectors/mlkem-recipient.key`](../test-vectors/mlkem-recipient.key) (`PqRecipientPrivateKey.Export()`, 2,400 bytes), SHA-256 `ff8599053e453e11aad3149736c7094484a39cf8d982ab9ab285956889ca5444` | +| Expected plaintext (UTF-8) | `PostQuantum.FileEncryption inline ML-KEM-768 recipient known-answer vector.` | + +--- + ## How to verify ```bash diff --git a/samples/PostQuantum.FileEncryption.Demo/Components/Pages/Home.razor b/samples/PostQuantum.FileEncryption.Demo/Components/Pages/Home.razor index f8d9fb7..944614c 100644 --- a/samples/PostQuantum.FileEncryption.Demo/Components/Pages/Home.razor +++ b/samples/PostQuantum.FileEncryption.Demo/Components/Pages/Home.razor @@ -142,7 +142,10 @@ { using var input = await ReadFileAsync(_decryptFile); using var output = new MemoryStream(); - await new PqFileDecryptor().DecryptAsync(input, output, _decryptPassphrase, progress, ct); + // Untrusted limits: uploads are anonymous, and a hostile ~200-byte header could + // otherwise legally demand 2 GiB of Argon2id memory per request (ANTI-PATTERNS.md). + await new PqFileDecryptor(PqDecryptionLimits.Untrusted) + .DecryptAsync(input, output, _decryptPassphrase, progress, ct); string outName = SuggestDecryptedName(_decryptFile.Name); await DownloadAsync(outName, output.ToArray()); return $"Decrypted {_decryptFile.Name} → {outName}."; diff --git a/samples/Pqfe.Cli/Program.cs b/samples/Pqfe.Cli/Program.cs index ec747aa..edfefb9 100644 --- a/samples/Pqfe.Cli/Program.cs +++ b/samples/Pqfe.Cli/Program.cs @@ -50,7 +50,10 @@ private static async Task Main(string[] args) using var cts = new CancellationTokenSource(); ConsoleCancelEventHandler onCancel = (_, e) => { - e.Cancel = true; + // First Ctrl+C: cooperative (cleanup runs). A second Ctrl+C is allowed to + // terminate the process, so a synchronous read the token cannot reach (e.g. a + // redirected-stdin passphrase read on a silent pipe) can always be escaped. + e.Cancel = !cts.IsCancellationRequested; // A Ctrl+C racing process exit can fire after the CTS is disposed; swallowing the // ObjectDisposedException here beats crashing on the very keystroke we intercepted. try { cts.Cancel(); } @@ -113,25 +116,48 @@ private static async Task EncryptAsync(string[] rest, CancellationToken can { if (!TryParsePaths(rest, out string? input, out string? output, out var flags)) return Fail("usage: pqfe encrypt [--argon2id] [--passphrase-env VAR] [--force]", ExitUsage); + if (flags.Untrusted) + return Fail("--untrusted applies to decrypt only.", ExitUsage); - if (!flags.Force && File.Exists(output)) - return Fail($"'{output}' already exists; refusing to overwrite (use --force).", ExitCantCreate); - - var options = new PqEncryptionOptions + // Claim the output path up front (not just File.Exists): the refusal contract must + // hold across the interactive passphrase prompt, which can stay open indefinitely — + // a bare existence check would silently clobber a file created during the prompt. + bool claimed = false; + if (!flags.Force) { - Kdf = flags.UseArgon2id ? PqKdf.Argon2id : PqKdf.Pbkdf2HmacSha256, - }; + switch (TryClaimOutput(output)) + { + case ClaimOutcome.Exists: + return Fail($"'{output}' already exists; refusing to overwrite (use --force).", ExitCantCreate); + case ClaimOutcome.Claimed: + claimed = true; + break; + } + } - byte[] passphrase = ReadPassphrase(flags.PassphraseEnv, confirm: true, cancellationToken); try { - var encryptor = new PqFileEncryptor(options); - var progress = new Progress(ReportProgress); - await encryptor.EncryptFileAsync(input, output, passphrase, progress, cancellationToken).ConfigureAwait(false); + var options = new PqEncryptionOptions + { + Kdf = flags.UseArgon2id ? PqKdf.Argon2id : PqKdf.Pbkdf2HmacSha256, + }; + + byte[] passphrase = ReadPassphrase(flags.PassphraseEnv, confirm: true, cancellationToken); + try + { + var encryptor = new PqFileEncryptor(options); + var progress = new Progress(ReportProgress); + await encryptor.EncryptFileAsync(input, output, passphrase, progress, cancellationToken).ConfigureAwait(false); + } + finally + { + CryptographicOperations.ZeroMemory(passphrase); + } } - finally + catch { - CryptographicOperations.ZeroMemory(passphrase); + if (claimed) ReleaseClaimedOutput(output); + throw; } Console.Error.WriteLine($"\nEncrypted {input} -> {output}"); @@ -141,21 +167,43 @@ private static async Task EncryptAsync(string[] rest, CancellationToken can private static async Task DecryptAsync(string[] rest, CancellationToken cancellationToken) { if (!TryParsePaths(rest, out string? input, out string? output, out var flags)) - return Fail("usage: pqfe decrypt [--passphrase-env VAR] [--force]", ExitUsage); + return Fail("usage: pqfe decrypt [--untrusted] [--passphrase-env VAR] [--force]", ExitUsage); - if (!flags.Force && File.Exists(output)) - return Fail($"'{output}' already exists; refusing to overwrite (use --force).", ExitCantCreate); + // See EncryptAsync: claim the path so the refusal holds across the passphrase prompt. + bool claimed = false; + if (!flags.Force) + { + switch (TryClaimOutput(output)) + { + case ClaimOutcome.Exists: + return Fail($"'{output}' already exists; refusing to overwrite (use --force).", ExitCantCreate); + case ClaimOutcome.Claimed: + claimed = true; + break; + } + } - byte[] passphrase = ReadPassphrase(flags.PassphraseEnv, confirm: false, cancellationToken); try { - var decryptor = new PqFileDecryptor(); - var progress = new Progress(ReportProgress); - await decryptor.DecryptFileAsync(input, output, passphrase, progress, cancellationToken).ConfigureAwait(false); + byte[] passphrase = ReadPassphrase(flags.PassphraseEnv, confirm: false, cancellationToken); + try + { + // --untrusted caps the KDF cost a hostile container header may demand (a + // ~90-byte file can otherwise legally ask for 2 GiB of Argon2id memory). + var decryptor = new PqFileDecryptor( + flags.Untrusted ? PqDecryptionLimits.Untrusted : PqDecryptionLimits.Default); + var progress = new Progress(ReportProgress); + await decryptor.DecryptFileAsync(input, output, passphrase, progress, cancellationToken).ConfigureAwait(false); + } + finally + { + CryptographicOperations.ZeroMemory(passphrase); + } } - finally + catch { - CryptographicOperations.ZeroMemory(passphrase); + if (claimed) ReleaseClaimedOutput(output); + throw; } Console.Error.WriteLine($"\nDecrypted {input} -> {output}"); @@ -212,6 +260,18 @@ private static async Task SignAsync(string[] rest, CancellationToken cancel if (!TryParseSigning(rest, out string? input, out string? keyPath, out string? signaturePath, out string? passphraseEnv)) return Fail("usage: pqfe sign [--signature PATH] [--passphrase-env VAR]", ExitUsage); + // --signature pointing at the input or the key file would atomically replace that + // file with the ~3 KB signature — silent data loss reported as success. Refuse. + StringComparison pathCmp = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + string fullSignature = Path.GetFullPath(signaturePath); + if (string.Equals(fullSignature, Path.GetFullPath(input), pathCmp) + || string.Equals(fullSignature, Path.GetFullPath(keyPath), pathCmp)) + { + return Fail("--signature must not point at the input or the key file.", ExitUsage); + } + byte[] keyBytes = await File.ReadAllBytesAsync(keyPath, cancellationToken).ConfigureAwait(false); try { @@ -411,6 +471,9 @@ private static bool TryParsePaths( case "--force": flags = flags with { Force = true }; break; + case "--untrusted": + flags = flags with { Untrusted = true }; + break; default: if (a.StartsWith('-')) return false; positionals.Add(a); @@ -424,6 +487,49 @@ private static bool TryParsePaths( return true; } + private enum ClaimOutcome { Exists, Claimed, NotClaimed } + + /// + /// Atomically claims the output path with a zero-byte placeholder so the overwrite + /// refusal cannot be raced by a file appearing while the passphrase prompt is open. + /// means the path could not be probed (e.g. its + /// directory does not exist) — proceed and let the library produce its clearer error. + /// + private static ClaimOutcome TryClaimOutput(string output) + { + try + { + new FileStream(output, FileMode.CreateNew, FileAccess.Write, FileShare.None).Dispose(); + return ClaimOutcome.Claimed; + } + catch (IOException) + { + return File.Exists(output) ? ClaimOutcome.Exists : ClaimOutcome.NotClaimed; + } + catch (UnauthorizedAccessException) + { + return ClaimOutcome.NotClaimed; + } + } + + /// Removes the placeholder left by — only if it is + /// still the empty file we created (never a file someone else has since written). + private static void ReleaseClaimedOutput(string output) + { + try + { + using (var probe = new FileStream(output, FileMode.Open, FileAccess.Read, FileShare.None)) + { + if (probe.Length != 0) return; + } + File.Delete(output); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort cleanup; the real failure is already propagating. + } + } + private static byte[] ReadPassphrase(string? envVar, bool confirm, CancellationToken cancellationToken) => Encoding.UTF8.GetBytes(ReadPassphraseString(envVar, confirm, cancellationToken)); @@ -536,7 +642,7 @@ private static void PrintUsage() Usage: pqfe encrypt [--argon2id] [--passphrase-env VAR] - pqfe decrypt [--passphrase-env VAR] + pqfe decrypt [--untrusted] [--passphrase-env VAR] pqfe keygen [--encrypt [--passphrase-env VAR]] pqfe sign [--signature PATH] [--passphrase-env VAR] pqfe verify [--signature PATH] @@ -552,6 +658,9 @@ Decryption reads the KDF from the container header — no flag needed. processes and can surface in crash dumps and process inspection — scope VAR to the single invocation. --signature PATH Detached-signature path (default: + ".sig"). + --untrusted (decrypt) Enforce PqDecryptionLimits.Untrusted: tight + ceilings on the KDF cost a hostile container header can + demand. Recommended for files from untrusted sources. --encrypt (keygen) Protect the private key file with a passphrase (PQKF format: an Argon2id-hardened .pqfe container). sign detects an encrypted key file automatically and @@ -568,7 +677,7 @@ 130 interrupted (Ctrl+C). """); } - private readonly record struct Flags(bool UseArgon2id, string? PassphraseEnv, bool Force); + private readonly record struct Flags(bool UseArgon2id, string? PassphraseEnv, bool Force, bool Untrusted); /// /// A usage-level failure raised deep in a helper. Main maps it to exit 64 after every diff --git a/samples/pqfe-wasm/fuzz/Cargo.toml b/samples/pqfe-wasm/fuzz/Cargo.toml index 671a72c..9377ba7 100644 --- a/samples/pqfe-wasm/fuzz/Cargo.toml +++ b/samples/pqfe-wasm/fuzz/Cargo.toml @@ -23,3 +23,10 @@ path = "fuzz_targets/decrypt.rs" test = false doc = false bench = false + +[[bin]] +name = "decrypt_hybrid" +path = "fuzz_targets/decrypt_hybrid.rs" +test = false +doc = false +bench = false diff --git a/samples/pqfe-wasm/fuzz/fuzz_targets/decrypt_hybrid.rs b/samples/pqfe-wasm/fuzz/fuzz_targets/decrypt_hybrid.rs new file mode 100644 index 0000000..d455740 --- /dev/null +++ b/samples/pqfe-wasm/fuzz/fuzz_targets/decrypt_hybrid.rs @@ -0,0 +1,17 @@ +#![no_main] +// Fuzzes the hybrid (KeySource 3/4) decrypt path. The passphrase target cannot reach it: +// decrypt_bytes returns Unsupported for these key sources right after the header parse, so +// try_unwrap_block / unwrap_multi — manual offset arithmetic over hostile bytes — had zero +// coverage-guided fuzzing. Using the pinned multi-recipient conformance key (its container is +// in the seed corpus) lets the fuzzer reach the unwrap-SUCCESS branch and the post-unwrap +// frame loop, not just the parse-and-reject paths. +use libfuzzer_sys::fuzz_target; + +const PRIVATE_KEY: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../test-vectors/lenient/multi-recipient-trailing.key" +)); + +fuzz_target!(|data: &[u8]| { + let _ = pqfe_wasm::decrypt_bytes_hybrid(data, PRIVATE_KEY); +}); diff --git a/samples/pqfe-wasm/fuzz/seed-corpus/hybrid-recipient.pqfe b/samples/pqfe-wasm/fuzz/seed-corpus/hybrid-recipient.pqfe new file mode 100644 index 0000000000000000000000000000000000000000..65a2895f45609f280052f97117fe1288b6bc1e0e GIT binary patch literal 1308 zcmV+%1>^cqQAR}q0RsR40RRAOpLj6@p8*6wrv>^L`>?UZ6O{ho(~_lD?7uTfQt$uc zq{nQhBYILIaxUz9+^lx$x-#j4fF68b{LMF$7tCZ5AWWbHTPvH+CbsClv}c}aGTrYR zZ1V{!iRdg0eZd3*rr_!PkcuxF2pppst6K0>@8f3Ea1OgW-xwYuM2g_Bl(hj3sFaCO zZB6a$jT#dvW#5S%Q^mAY1>eR|{ z37k8yfcp+Tur0ob{|WW|VWJ*f4&eD0e?dX)(fr%yGEg_L!$_e^tI+tCUKj?HF9+6p zCiZ!B|2Xp9a9aOydQ>oc^3Ki_@vXiK_(JF5fIp}9N3WbMvtGcQhXxnBi=hr^ImsX8 zBv@7D(`$W=^FNC%TmoGukir5I0DE)0l@S4CwkI1l@!a{=SO8s4$-JW|jv)eLpHQ9o$dT9-NuN{rFb3F`QAda=t zfoY`(K&3)Lq|72<5NU zC2U;nksnI{g^7KMrWV8cbkRdQI~=U*T0G#X(A?eTKR-O04;#-!>Ws|MWu->UCaa!g zD*EgZx@D5oJ5Lrkh9|iplhA9LzF^P|ZxD0-OF6|$On68DluJXvm_#a4?ec-sH30ax z_Y@5tV!1K3#6XCCXtslk)8bY~w5B#WugB=A8Ec4%=Tty3TIR>0Hl(7vFqn-W*iq=F zudg~zr9t?0(cn9q=~|91!TY_5kNJ;zsDgY8rNH#>(lqJl*ttabUjBUt zD{-h?;B;eQ#Yltmikw_L9D%uTRuR(lKX9$A0uelcU^t3Be-dVY2SJ>J)X^p7fuQ(D z5F44e@`hw|Uk>Gp9L_~+v{Qfz-Ey4kgwC%5Wr5jGMM(gK0|=n)Rgxqvpf16Pgy~4M zRQ@8$`o=-aEd@E++J}A)to7GXo=PMIJpCPYfWGoLqa^bgVC%DhXDmGH5(qVb%@?Y_ z#LQD+%BsuUJ^pHq)voS5;b3s`PMwx;=8%^z2cnXrR;9a~P&$g_ry+?>r$L#;KzKG2 zvoT5mTFVihg(C)*C;uQN(U;O#U*8VDR84n0@@o%J?Qj)=t;^`gZqa;^E_=42=3Y%- z?VFL2=jg15##-udV>*`#@@HznyJkRr$>^mXq7JR%;irMQ=nC}G9C-d%-t8};6|jNC zM}hYRKUY8Eh+=8d$1|jC?GY9bX|rcVN`o;Lt+3HTsawWeRG6OlPr%jnw@3{RfC^Rn z^W<`70cz^RvGrR5#+Rl5o5l;`N|o{z_S;#K*LawgU;{!_H-l;3o$$tST_&?FQdu}x z{98+amk0WGF=n!i5a`T`)2D5aViEUlo_M=|m0}=q7O=32vi!%6h5L^l(EcRCx1K1{ zz{!tzwE8AIS-zZZwAtt(tXq*HZ~9bfvF%h%hCwI*bHmLOlWPZ#0aYou`Y{{zK5}bFulKKs^t^x)N{{olli`XmT+2>|T(Nd{Yc7xLg$LrF zA9Au{$I^c-xqG1v#4ZKd6|n-^KvuieDusHsK5Es%CY`u9I< SI0&J@ZZ_=|dbOTT;4X3SiHzL< literal 0 HcmV?d00001 diff --git a/samples/pqfe-wasm/fuzz/seed-corpus/multi-recipient-trailing.pqfe b/samples/pqfe-wasm/fuzz/seed-corpus/multi-recipient-trailing.pqfe new file mode 100644 index 0000000000000000000000000000000000000000..4b18a73bf2eb66acebcd754a2871f5346c65de36 GIT binary patch literal 2498 zcmV;z2|e~uQAR}q0R#X500aOtGcz*@MF9f@p8*6wv4eFh!u}{US_-azE4!$aAZcYr z-UHPxHgR!C2L@EwH4i!5)U01qBy!?24WZ9*c&KC4=Qzk;k-t!Q)eadjKXT#)j1v&| z^%K$7n|F<8$Sz3y?iz=Z21eu?eT%K!2v7*8%uk~UOW7Jli{kk&-lS$=7BkN+W2 zywah+mOeRicpC|<&&M5wRJUBaL&6$Y`L$W#A&R~7qNK{*9DU-(ewE?>K@_D#t9C^m zt&@8cE0N(j&zgi1Ha1_dIA4yW{3k0WkzMF^zxXXs#&=4ASBr|jYR;l-Jg*^+RgH>AS5aIuU_b~5&VzUp)tjCeRfG^0wVp*e^+X?5X-moZmD}@A0!Xd!YRwMlI$X8OU z#e&Wn%7V|0?eb68o^x{~6ujWXYuDo3hDwZb8%w^@T)_Vj_5>+uw7?V$^Wx|JL=pF= zyyG%t(a{A?S1-45xb_*@F8C--w90? zFmIu(_JIIZ6dU$yEIR;6Sg&;N)A((+wp_t+!Y+b?6`7m$^?oah9@GZjPk2-q(BN1F zJo4dtb3@6eo@+&|>l}2YLsIalTJ-BN%Hywv7SK_r5UT03Y`)8qsU+m!krpfgro3?i ztb`1t`+CG8AL9b9a+S6BV#{(Ue4u-N9Z$s~X2<2r7~8?}$3S#7 zxSx(@Rfq`xEn>r*TZ53tsqN-m7M6~7gw6>3Y8hoGW=%UP4dBndUILRSGEX!aU4Oq1 zEm1wN%iN_sd)pT3+`m;*QcF?;?dQ&a@BaoaivB!Kd9=9gM_1vfG&gUf_vLuj%e1bGpxFjO4D;}fuBT^Z!Bpu-ETv+GaWUxd3DDKv=v*4Zftw|o+&UkilcxAx zqfAj!-aC;60Y%!am--(O42>Z=n#kau>{4_1zvz9m8&UMpVN1eiJ)+tHk~ml7RaWE7 zu+*@FN-I51Vk%;}S9i21H|d*c9Dh@8#TxR@Ui!!5Fb!~ecCJ}eP9pH5{@j~iUT+-C z|Ms`#c5Q73cl=VJ{1^?f0|cJ|1VH{swZQIlI}gxJM))RO4&Fm~3fi8KwD+|HjT1yG zSgog=by2v5AepqUZ4b878FQc4c7FdX!Io@R37Ek|AbVbhzt#pHFcSo1VGm0+hdEUE zxq=gkCNi=w1;b`#gjulg_eq=BWM@pbg3aDR&~N=eM{9+);u9a`xiu zAtUAb!&3kXoRktUdRUYKi;%1`ACf_a|pA0eZEXJo*P24Y$K=k z3lL`D^IGh#HvwlxII0s|;T}BiyR+6_Buuydx|#bhV?c``M^qQ=$v-Ud#iz3KifqdB zA}_YK0eyy7OuyPotI|9(AK{H2Jn*tdxq#MadG$14Xg3NJxsYA0_rQ{(1nZ*XB%`e> z!C1%biCexlOgwiTh5P>3z!H_UC!$AVU!*0mx+_TAM);-z5X3@BZW2XBBtV95~JtRC9CprK?zDF9Y6w+HjPnZ#rMzZ_x0 zp^jTGWFAVs=?a6}jm6b=fh6nNv8SZ`JUUctzakB-ur#Qd#Uqvnp2DhUCA@C~Cn0@V zez{3Ymi!sBM&|;Ljo04YrUmS^-px^002|Y$Lr6b?=W;roASDJ<%PZ4S>ze{iD5WnWCrvA7v*gJb%gb(y> zyNbYd{!E@4WM97Q3T%RcQBLU%mFrUImdrH+(&NrWcM_&z>cPopPhJQflaN_5PIj(< z+{>vhvY~$7vNS-b99mRRG(peYk+X(<_V(((#F$|TtI*$XF@5!zfN?}B8qObkF|W0d zn?+h8(%9+_q`u{bA?2lS z*v+CtY{d*vs)~7=j0qVe#_6iCs2-hyIXe50FqvvC_LN#fFM5WR4y;dlaHe;J9sz0G zRL?}RfRTQ2x7xbbbL8rfpopLRFe9AeYU|M!g591L;9eT}&s(^0ew#~$0FqNY8FiUw z9ICLCjZiw;?DZ`?#e|gWB~uj^gSA7uo6r0X?M2;>wIj6CTpYWv{@E*^Wf4R707)cA zH@!M)A4>rM0035ugtmNR1t{yy;a`%&f}ldw^s~6CMtIrlyvHF0u{z(e8RBz_sXJ65 zGyb7BU22Ll^J?NR>r^yd1P=(l%IP Vec { + // This test-facing writer must enforce the same parameter ranges as the .NET writer's + // PqEncryptionOptions.Validate: without these, chunk_size = 0 loops forever appending + // empty frames, a nonce prefix that is not 4 bytes panics mid-write, and a salt longer + // than 255 bytes silently truncates its length byte — producing a container no + // conforming reader can ever decrypt (silent data loss for a caller who kept only the + // ciphertext). + assert!( + (MIN_SALT_LEN..=255).contains(&salt.len()), + "salt must be 8..=255 bytes" + ); + assert!( + nonce_prefix.len() == NONCE_PREFIX_LEN, + "nonce prefix must be exactly 4 bytes" + ); + assert!( + (MIN_PBKDF2_ITERS..=MAX_PBKDF2_ITERS).contains(&iterations), + "iterations out of the format range" + ); + assert!( + (MIN_CHUNK..=MAX_CHUNK).contains(&chunk_size), + "chunk size out of the format range" + ); let key = derive_pbkdf2(passphrase, salt, iterations); // KeyParams (passphrase / PBKDF2): KdfId | SaltLen | Salt | Iterations(u32 BE) diff --git a/samples/pqfe-web/app.js b/samples/pqfe-web/app.js index 6f19157..4abbf2a 100644 --- a/samples/pqfe-web/app.js +++ b/samples/pqfe-web/app.js @@ -54,7 +54,15 @@ function createWorkerBackend() { pending.delete(m.id); m.ok ? p.res(m.result) : p.rej(new Error(m.error)); }; - worker.onerror = (e) => { clearTimeout(timer); reject(new Error(e.message || 'worker error')); }; + worker.onerror = (e) => { + clearTimeout(timer); + const err = new Error(e.message || 'worker error'); + reject(err); // no-op if init already resolved + // A worker crash after init must not strand in-flight operations: reject them all + // so the UI shows the error instead of hanging at "Encrypting…" forever. + for (const p of pending.values()) p.rej(err); + pending.clear(); + }; }); } diff --git a/samples/quickstarts/Pqfe.QuickStart.Backup/Program.cs b/samples/quickstarts/Pqfe.QuickStart.Backup/Program.cs index 209d8f5..6794ce4 100644 --- a/samples/quickstarts/Pqfe.QuickStart.Backup/Program.cs +++ b/samples/quickstarts/Pqfe.QuickStart.Backup/Program.cs @@ -71,6 +71,13 @@ Console.Error.WriteLine($"FAILED {name}: wrong passphrase, or the file was altered"); failures++; } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A locked, unreadable, or concurrently deleted file must not abort the whole run + // with a raw stack trace — report it like the other per-file failures and move on. + Console.Error.WriteLine($"FAILED {name}: {ex.Message}"); + failures++; + } } } catch (OperationCanceledException) diff --git a/samples/quickstarts/Pqfe.QuickStart.WebUpload.Keygen/Program.cs b/samples/quickstarts/Pqfe.QuickStart.WebUpload.Keygen/Program.cs index 8df5d8e..d71c75b 100644 --- a/samples/quickstarts/Pqfe.QuickStart.WebUpload.Keygen/Program.cs +++ b/samples/quickstarts/Pqfe.QuickStart.WebUpload.Keygen/Program.cs @@ -11,6 +11,12 @@ // The private-key file is passphrase-protected. Provide the passphrase via the PQFE_PASS // environment variable, or you will be prompted for it. The private key is written ONLY in the // encrypted PQKF form (ExportEncrypted), never as raw Export() bytes. +// +// Both outputs are opened CreateNew: overwriting an existing key pair would permanently orphan +// every container already encrypted to the old public key, so an existing file is a hard error, +// never a silent replace. The private key is written first (owner-only permissions on Unix); if +// the public half then fails to write, the private file is removed so no mismatched half-pair +// is left behind. using System.Security.Cryptography; using PostQuantum.FileEncryption.Hybrid; @@ -36,10 +42,27 @@ await Console.Error.WriteLineAsync( try { using var keyPair = PqHybridKeyPair.Generate(); - await File.WriteAllBytesAsync(publicOut, keyPair.PublicKey.Export()); // ExportEncrypted (not Export): the private key is written only as an authenticated, // Argon2id-hardened PQKF file that fails closed on a wrong passphrase or any tampering. - await File.WriteAllBytesAsync(privateOut, keyPair.PrivateKey.ExportEncrypted(passphrase)); + await WriteNewFileAsync(privateOut, keyPair.PrivateKey.ExportEncrypted(passphrase), ownerOnly: true); + try + { + await WriteNewFileAsync(publicOut, keyPair.PublicKey.Export(), ownerOnly: false); + } + catch + { + // Never leave a private key whose public half failed to materialize. + try { File.Delete(privateOut); } catch (IOException) { } + throw; + } +} +catch (IOException ex) +{ + await Console.Error.WriteLineAsync( + $"error: {ex.Message}\nRefusing to overwrite an existing key file — replacing a key pair " + + "would permanently orphan every upload already encrypted to the old public key. " + + "Move the existing files aside first if you really mean to rotate."); + return 73; // sysexits.h EX_CANTCREAT } finally { @@ -50,6 +73,22 @@ await Console.Error.WriteLineAsync( Console.WriteLine($"Wrote private key -> {privateOut} (keep secret; never on the web server)"); return 0; +static async Task WriteNewFileAsync(string path, byte[] bytes, bool ownerOnly) +{ + var options = new FileStreamOptions + { + Mode = FileMode.CreateNew, // an existing file is an error, never a silent overwrite + Access = FileAccess.Write, + Share = FileShare.None, + }; + if (ownerOnly && !OperatingSystem.IsWindows()) + { + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; // 0600, like ssh-keygen + } + await using var stream = new FileStream(path, options); + await stream.WriteAsync(bytes); +} + static char[]? ReadPassphrase() { string? fromEnv = Environment.GetEnvironmentVariable("PQFE_PASS"); diff --git a/scripts/check-docs-consistency.sh b/scripts/check-docs-consistency.sh index fe51af8..d75afca 100644 --- a/scripts/check-docs-consistency.sh +++ b/scripts/check-docs-consistency.sh @@ -131,7 +131,7 @@ while IFS= read -r mdfile; do echo "::error file=$mdfile,line=$lineno::dead internal link '$target' (no file at '$dir/$path')" >&2 mark_fail fi - done < <(perl -ne 'while (/\]\(([^)]+)\)/g) { print "$.:$1\n" }' "$mdfile" 2>/dev/null || true) + done < <(perl -ne 'while (/\]\(\s*]+)>?(?:\s[^)]*)?\)/g) { my $t = $1; $t =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/ge; print "$.:$t\n" }' "$mdfile" 2>/dev/null || true) done < <(git ls-files '*.md') # ------------------------------------------------------------------ verdict diff --git a/src/PostQuantum.FileEncryption.AzureKeyVault/AzureKeyVaultContentKeyProvider.cs b/src/PostQuantum.FileEncryption.AzureKeyVault/AzureKeyVaultContentKeyProvider.cs index 1e2288f..7458813 100644 --- a/src/PostQuantum.FileEncryption.AzureKeyVault/AzureKeyVaultContentKeyProvider.cs +++ b/src/PostQuantum.FileEncryption.AzureKeyVault/AzureKeyVaultContentKeyProvider.cs @@ -115,9 +115,11 @@ public async Task UnwrapKeyAsync(ReadOnlyMemory wrapInfo, Cancella if (!KeyIdMatchesConfiguredKey(recordedKeyId)) { // A clear operational error, like LocalKek's wrong-length message: the caller is - // holding the wrong provider, not (necessarily) a tampered file. + // holding the wrong provider, not (necessarily) a tampered file. The recorded id + // comes from the unauthenticated header, so control characters are neutralized + // before it can reach a log or terminal (mirrors PqContainer.SanitizeForMessage). throw new PqDecryptionException( - $"The container's content key was wrapped under Key Vault key '{recordedKeyId}', " + + $"The container's content key was wrapped under Key Vault key '{SanitizeForMessage(recordedKeyId)}', " + $"but this provider is configured for '{_client.KeyId}'."); } @@ -153,7 +155,35 @@ public async Task UnwrapKeyAsync(ReadOnlyMemory wrapInfo, Cancella /// match the versioned id the service recorded (Key Vault ids are /// https://vault/keys/name[/version]). /// - private bool KeyIdMatchesConfiguredKey(string recordedKeyId) => - string.Equals(recordedKeyId, _client.KeyId, StringComparison.Ordinal) || - recordedKeyId.StartsWith(_client.KeyId + "/", StringComparison.Ordinal); + private bool KeyIdMatchesConfiguredKey(string recordedKeyId) + { + string? configured = _client.KeyId; + if (configured is null) + { + // A locally-operating client built from a JsonWebKey with no key id cannot pin — + // and the naive comparison below would both reject every legitimate id with a + // nonsense message AND accept any hostile recorded id starting with '/' + // (null + "/" == "/", an accidental wildcard). Defer to the cryptographic unwrap: + // a wrong or tampered wrap still fails closed at the unwrap step. + return true; + } + return string.Equals(recordedKeyId, configured, StringComparison.Ordinal) || + recordedKeyId.StartsWith(configured + "/", StringComparison.Ordinal); + } + + /// + /// Replaces control characters in header-derived text before embedding it in an exception + /// message, so a crafted container cannot inject terminal escape sequences or forged lines + /// into whatever log or console the caller writes the message to. + /// + private static string SanitizeForMessage(string value) + { + Span buffer = value.Length <= 256 ? stackalloc char[value.Length] : new char[value.Length]; + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + buffer[i] = char.IsControl(c) ? '?' : c; + } + return new string(buffer); + } } diff --git a/src/PostQuantum.FileEncryption.Hybrid/Internal/HybridKeyEstablishment.cs b/src/PostQuantum.FileEncryption.Hybrid/Internal/HybridKeyEstablishment.cs index a2ab267..cd6091d 100644 --- a/src/PostQuantum.FileEncryption.Hybrid/Internal/HybridKeyEstablishment.cs +++ b/src/PostQuantum.FileEncryption.Hybrid/Internal/HybridKeyEstablishment.cs @@ -118,7 +118,10 @@ public static byte[] WrapToRecipient(PqHybridPublicKey recipient, byte[] cek) public static byte[] UnwrapFromRecipient(ReadOnlySpan body, PqHybridPrivateKey key) => TryUnwrapBlock(body, key) ?? throw new PqDecryptionException( - "Decryption failed — this file is not encrypted to this key, or it has been altered."); + // The engine's exact generic literal: which STAGE failed (key unwrap here vs. + // body authentication later) must not be observable, or a service exposing raw + // errors becomes a key-possession oracle. + "Decryption failed — the passphrase (or key) is wrong, or the file has been altered, truncated, or corrupted."); // ---- multiple recipients (KeySource = 4) ---- @@ -182,7 +185,8 @@ public static byte[] UnwrapFromRecipients(ReadOnlySpan body, PqHybridPriva } throw new PqDecryptionException( - "Decryption failed — none of the recipients in this file match this key, or it has been altered."); + // Same generic literal as the engine and the single-recipient path — see above. + "Decryption failed — the passphrase (or key) is wrong, or the file has been altered, truncated, or corrupted."); } // ---- shared ---- diff --git a/src/PostQuantum.FileEncryption.Hybrid/PqHybridEncryptor.cs b/src/PostQuantum.FileEncryption.Hybrid/PqHybridEncryptor.cs index 3fcde7f..858d238 100644 --- a/src/PostQuantum.FileEncryption.Hybrid/PqHybridEncryptor.cs +++ b/src/PostQuantum.FileEncryption.Hybrid/PqHybridEncryptor.cs @@ -100,7 +100,9 @@ public async Task EncryptBytesToAsync( { ValidateRecipients(recipients); using var input = new MemoryStream(plaintext.ToArray(), writable: false); - using var output = new MemoryStream(plaintext.Length + 1536); + // Capacity hint clamped: for plaintext within 1536 bytes of Array.MaxLength the naive + // int addition wraps negative and MemoryStream(int) throws a raw ArgumentOutOfRangeException. + using var output = new MemoryStream((int)Math.Min(plaintext.Length + 1536L, Array.MaxLength)); await EncryptToAsync(input, output, recipients, plaintext.Length, null, cancellationToken).ConfigureAwait(false); return output.ToArray(); } diff --git a/src/PostQuantum.FileEncryption.Signing/PqSigner.cs b/src/PostQuantum.FileEncryption.Signing/PqSigner.cs index 29600f1..65b8dc3 100644 --- a/src/PostQuantum.FileEncryption.Signing/PqSigner.cs +++ b/src/PostQuantum.FileEncryption.Signing/PqSigner.cs @@ -31,6 +31,17 @@ public async Task SignFileAsync( ArgumentException.ThrowIfNullOrEmpty(inputPath); ArgumentException.ThrowIfNullOrEmpty(signaturePath); ArgumentNullException.ThrowIfNull(privateKey); + // A detached-signature writer has no meaningful in-place mode: the same path for input + // and signature would atomically replace the signed file with its own signature — + // silent data loss producing a signature that can never verify. Refuse up front. + // (Best-effort: case-insensitive on the OSes whose default filesystems are.) + StringComparison pathComparison = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (string.Equals(Path.GetFullPath(inputPath), Path.GetFullPath(signaturePath), pathComparison)) + { + throw new ArgumentException("The signature path must differ from the input path.", nameof(signaturePath)); + } byte[] signature; await using (var input = FileIo.OpenRead(inputPath)) diff --git a/src/PostQuantum.FileEncryption/Internal/KeyEstablishment.cs b/src/PostQuantum.FileEncryption/Internal/KeyEstablishment.cs index bcbaad6..d839faf 100644 --- a/src/PostQuantum.FileEncryption/Internal/KeyEstablishment.cs +++ b/src/PostQuantum.FileEncryption/Internal/KeyEstablishment.cs @@ -121,6 +121,17 @@ public static async Task DerivePassphraseKeyAsync( throw new PqFormatException( $"Container demands Argon2id parallelism {parallelism}, above this decryptor's configured limit of {limits.MaxArgon2Parallelism} (see PqDecryptionLimits)."); } + if (passphrase.IsEmpty) + { + // Konscious rejects an empty password with a raw ArgumentException. The + // byte-passphrase overloads deliberately accept empty input for legacy + // containers (see PqContainer.EncryptPassphraseAsync), and an empty + // passphrase against a PBKDF2 header runs the KDF and fails with the + // uniform PqDecryptionException — so this path must fail the same way, + // not with a third-party exception steered by an unauthenticated header byte. + throw new PqDecryptionException( + "Decryption failed — the passphrase (or key) is wrong, or the file has been altered, truncated, or corrupted."); + } return await DeriveArgon2idAsync(passphrase, salt, (int)memoryKiB, (int)iterations, parallelism).ConfigureAwait(false); } default: @@ -287,8 +298,9 @@ public static byte[] UnwrapRecipientKey(ContainerHeader header, PqRecipientPriva // PqRecipientPrivateKey.Import validates only the length, so a corrupt or bit-rotted // stored key fails FIPS 203 decode here; mirror the encrypt side and keep it inside // the library's exception contract instead of leaking a platform exception. + _ = ex; // key-dependent failure: no inner exception may distinguish the stage throw new PqDecryptionException( - "Decryption failed: the recipient key is wrong, or the container has been altered.", ex); + "Decryption failed — the passphrase (or key) is wrong, or the file has been altered, truncated, or corrupted."); } using MLKem _decapsulationKey = decapsulationKey; @@ -303,10 +315,11 @@ public static byte[] UnwrapRecipientKey(ContainerHeader header, PqRecipientPriva gcm.Decrypt(wrapNonce, wrappedKey, wrapTag, contentKey, WrapAad); return contentKey; } - catch (AuthenticationTagMismatchException ex) + catch (AuthenticationTagMismatchException) { + // Same generic literal as the engine: no stage-distinguishing message or inner. throw new PqDecryptionException( - "Decryption failed: the recipient key is wrong, or the container has been altered.", ex); + "Decryption failed — the passphrase (or key) is wrong, or the file has been altered, truncated, or corrupted."); } finally { diff --git a/src/PostQuantum.FileEncryption/Internal/PqContainer.cs b/src/PostQuantum.FileEncryption/Internal/PqContainer.cs index 46c277d..123e257 100644 --- a/src/PostQuantum.FileEncryption/Internal/PqContainer.cs +++ b/src/PostQuantum.FileEncryption/Internal/PqContainer.cs @@ -204,7 +204,10 @@ private static byte[] SerializeKeyProviderParams(string providerId, byte[] wrapI { throw new ArgumentException("Provider id must be between 1 and 255 UTF-8 bytes.", nameof(providerId)); } - if (wrapInfo.Length > ushort.MaxValue) + // The real ceiling is what fits the header's uint16 KeyParams field alongside the id + // and length prefix — checking only ushort.MaxValue here would let the last few bytes + // of the range escape as ContainerHeader.Create's internal-parameter ArgumentException. + if (wrapInfo.Length > ContainerFormat.MaxKeyParamsLength - 3 - id.Length) { throw new ArgumentException("Provider wrap info is too large.", nameof(wrapInfo)); } diff --git a/src/PostQuantum.FileEncryption/Internal/PqContainerEngine.cs b/src/PostQuantum.FileEncryption/Internal/PqContainerEngine.cs index b7292f0..fcdbc6c 100644 --- a/src/PostQuantum.FileEncryption/Internal/PqContainerEngine.cs +++ b/src/PostQuantum.FileEncryption/Internal/PqContainerEngine.cs @@ -57,6 +57,11 @@ public static async Task EncryptCoreAsync( IProgress? progress, CancellationToken cancellationToken) { + // Declared outside the try so the finally can zero them: for key-file (PQKF) traffic + // the "plaintext" flowing through these staging buffers IS a private key, so they get + // the same hygiene as the content key. + byte[] current = []; + byte[] next = []; try { using var aes = new AesGcm(contentKey, ContainerFormat.TagLength); @@ -66,8 +71,8 @@ public static async Task EncryptCoreAsync( // Two plaintext buffers let us read one chunk ahead, so we always know whether the // chunk we are about to seal is the final one. ReadAtMostAsync only returns a short // count at end-of-stream, so a partial fill reliably means "no more data". - byte[] current = new byte[header.ChunkSize]; - byte[] next = new byte[header.ChunkSize]; + current = new byte[header.ChunkSize]; + next = new byte[header.ChunkSize]; byte[] ciphertext = new byte[header.ChunkSize]; byte[] tag = new byte[ContainerFormat.TagLength]; byte[] nonce = new byte[ContainerFormat.NonceLength]; @@ -117,6 +122,8 @@ public static async Task EncryptCoreAsync( finally { CryptographicOperations.ZeroMemory(contentKey); + CryptographicOperations.ZeroMemory(current); + CryptographicOperations.ZeroMemory(next); } } @@ -136,6 +143,8 @@ public static async Task DecryptCoreAsync( IProgress? progress, CancellationToken cancellationToken) { + // Declared outside the try so the finally can zero it — see EncryptCoreAsync. + byte[] plaintext = []; try { long? totalPlaintextBytes = DerivePlaintextTotal(totalContainerBytes, header); @@ -154,7 +163,7 @@ public static async Task DecryptCoreAsync( } byte[] ciphertext = new byte[bufferSize]; - byte[] plaintext = new byte[bufferSize]; + plaintext = new byte[bufferSize]; byte[] tag = new byte[ContainerFormat.TagLength]; byte[] nonce = new byte[ContainerFormat.NonceLength]; byte[] frameHeader = new byte[5]; @@ -210,10 +219,13 @@ await ReadExactAsync(source, tag, cancellationToken).ConfigureAwait(false) != ta // throws and writes nothing, so corrupt data never reaches `destination`. aes.Decrypt(nonce, ciphertext.AsSpan(0, (int)length), tag, plaintext.AsSpan(0, (int)length), aad); } - catch (AuthenticationTagMismatchException ex) + catch (AuthenticationTagMismatchException) { + // Deliberately no inner exception: every in-library key-dependent failure + // (wrong passphrase, wrong recipient/KEK, tampered wrap, tampered body) + // must be indistinguishable to the caller — message AND exception shape. throw new PqDecryptionException( - "Decryption failed — the passphrase (or key) is wrong, or the file has been altered, truncated, or corrupted.", ex); + "Decryption failed — the passphrase (or key) is wrong, or the file has been altered, truncated, or corrupted."); } await destination.WriteAsync(plaintext.AsMemory(0, (int)length), cancellationToken).ConfigureAwait(false); @@ -240,6 +252,7 @@ await ReadExactAsync(source, tag, cancellationToken).ConfigureAwait(false) != ta finally { CryptographicOperations.ZeroMemory(contentKey); + CryptographicOperations.ZeroMemory(plaintext); } } diff --git a/src/PostQuantum.FileEncryption/Internal/PqKeyFileFormat.cs b/src/PostQuantum.FileEncryption/Internal/PqKeyFileFormat.cs index dded9de..95e87fa 100644 --- a/src/PostQuantum.FileEncryption/Internal/PqKeyFileFormat.cs +++ b/src/PostQuantum.FileEncryption/Internal/PqKeyFileFormat.cs @@ -121,7 +121,7 @@ public static byte[] Decrypt( input.Length, progress: null, CancellationToken.None) .GetAwaiter().GetResult(); } - catch (NotSupportedException) + catch (NotSupportedException ex) when (ex is not PlatformNotSupportedException) { // The fixed-capacity stream refused a write past 1 + expectedKeyLength + 1: // the (authenticated) plaintext is larger than any key of the expected type, diff --git a/src/PostQuantum.FileEncryption/LocalKekContentKeyProvider.cs b/src/PostQuantum.FileEncryption/LocalKekContentKeyProvider.cs index 5337108..64c232f 100644 --- a/src/PostQuantum.FileEncryption/LocalKekContentKeyProvider.cs +++ b/src/PostQuantum.FileEncryption/LocalKekContentKeyProvider.cs @@ -46,7 +46,17 @@ public LocalKekContentKeyProvider(ReadOnlySpan kek) throw new ArgumentException($"The key-encryption key must be {KekLength} bytes (256 bits).", nameof(kek)); } _kek = kek.ToArray(); - _kekGcm = new AesGcm(_kek, TagLength); + try + { + _kekGcm = new AesGcm(_kek, TagLength); + } + catch + { + // AesGcm can throw (e.g. PlatformNotSupportedException) after the KEK was cloned; + // the half-constructed object can never be disposed, so zero the clone here. + CryptographicOperations.ZeroMemory(_kek); + throw; + } } /// Creates a provider over a fresh random 256-bit KEK (e.g. for tests). @@ -141,11 +151,13 @@ public Task UnwrapKeyAsync(ReadOnlyMemory wrapInfo, CancellationTo } return Task.FromResult(contentKey); } - catch (AuthenticationTagMismatchException ex) + catch (AuthenticationTagMismatchException) { CryptographicOperations.ZeroMemory(contentKey); + // Same generic literal as the engine: a wrong KEK and a tampered body must be + // indistinguishable to the caller — message and exception shape alike. throw new PqDecryptionException( - "Decryption failed: wrong key-encryption key, or the wrapped key has been altered.", ex); + "Decryption failed — the passphrase (or key) is wrong, or the file has been altered, truncated, or corrupted."); } } diff --git a/src/PostQuantum.FileEncryption/PqFileEncryptor.cs b/src/PostQuantum.FileEncryption/PqFileEncryptor.cs index 76f1ed9..d5ba05a 100644 --- a/src/PostQuantum.FileEncryption/PqFileEncryptor.cs +++ b/src/PostQuantum.FileEncryption/PqFileEncryptor.cs @@ -176,7 +176,9 @@ public async Task EncryptBytesAsync( { ArgumentNullException.ThrowIfNull(keyProvider); using var input = new MemoryStream(plaintext.ToArray(), writable: false); - using var output = new MemoryStream(plaintext.Length + 256); + // Capacity hint clamped: for plaintext within 256 bytes of Array.MaxLength the naive + // int addition wraps negative and MemoryStream(int) throws a raw ArgumentOutOfRangeException. + using var output = new MemoryStream((int)Math.Min(plaintext.Length + 256L, Array.MaxLength)); await PqContainer.EncryptKeyProviderAsync( input, output, keyProvider, _options, plaintext.Length, progress, cancellationToken).ConfigureAwait(false); return output.ToArray(); @@ -212,7 +214,9 @@ public async Task EncryptBytesAsync( // Empty passphrases are rejected once at the engine choke point (PqContainer), so // every overload — and any future codec caller — inherits the same gate. using var input = new MemoryStream(plaintext.ToArray(), writable: false); - using var output = new MemoryStream(plaintext.Length + 256); + // Capacity hint clamped: for plaintext within 256 bytes of Array.MaxLength the naive + // int addition wraps negative and MemoryStream(int) throws a raw ArgumentOutOfRangeException. + using var output = new MemoryStream((int)Math.Min(plaintext.Length + 256L, Array.MaxLength)); await PqContainer.EncryptPassphraseAsync( input, output, passphrase, _options, plaintext.Length, progress, cancellationToken).ConfigureAwait(false); return output.ToArray(); diff --git a/test-vectors/SHA256SUMS b/test-vectors/SHA256SUMS index e4990d8..54961b6 100644 --- a/test-vectors/SHA256SUMS +++ b/test-vectors/SHA256SUMS @@ -1,4 +1,6 @@ a16ff8db3dad6a50d9a81cee5a97ce26d875c8dce80a00c93dd7516f080d31de *hybrid-recipient.pqfe +02d3614753172b9eb9690cb35325794fac5e9a67faf5f81377b708725ed00503 *mlkem-recipient.pqfe +ff8599053e453e11aad3149736c7094484a39cf8d982ab9ab285956889ca5444 *mlkem-recipient.key 4e165d1238fcad436bad8b7cd72072b9196e4492aaddfcddfbc82029f0eca4ee *passphrase-argon2id.pqfe ab32cc1d2f5f673d77d80fc2f45307abe4a33a35552f2b5c677a9c5818718547 *passphrase-pbkdf2.pqfe b428f6492c78fe03b8b3197872e60bd737764be066cdaddab594f06f18e6ade6 *passphrase-pbkdf2-rustcore.pqfe diff --git a/test-vectors/mlkem-recipient.key b/test-vectors/mlkem-recipient.key new file mode 100644 index 0000000000000000000000000000000000000000..591712c07df8fd7f175add3e45cd7bbfcfd69de0 GIT binary patch literal 2400 zcmV-m37_`;I58wP9mA3}aulo1C>oC}y8@Pyyy375Cb?Xv#?vaJxjMWX;)S+4rATus zNLx)_l%eBcY|92gJ@Rm;h)xyTW<3W~3n~L5!F4tFAnF&T)KsO}awn#w2Os928?;

!x-|^Ch9~HgAYiZ5ITC;T zCjW*LW)^30mPLipv*_j~hOxW#t3R~LUXP}Gk%>^$8O-!C6Somk(|d75=b^$Wcu}(o zg3GzOgAW~~!ib&`~}Bd;`Hg=}#H!a8Y)qAK)ultY2d zrILk83J15Sq$hJg7O*=2baf(x+C&Yy%canX%pq26w1|4B^E#@^yGq-=gn}SV#40vA?|RP_MZIgGGr_N~PU6Ssi-^{erl+Iv|x5BBaKV zCZbgf!` zdAhs=c|=2WAp;S~>if4ok|I+Ewmr-@Jab8*`wOBIFNs4uV!4#dLR|PtIA*Ff>xBe#t56ojbPklK#BsuXha64niQW9b&0(b``-W%(1x;wo5fm*HcE#Jv zS+{qYZF<5&EDp0wB;=VxXvac(lTc&Rh*oUO#Aq4Xxk4_`IG9Y!JT#WWb#!SAwPz}E zhGj|)reBz{nn@vNjh2Moh5%d_5Z-JRN7#uj}z}jpv+yqYJ5Q!A_2;lBgy_09vJ>jCngZn7rDR zA>7X2Y6-s>L#*Oyxww#gP>XUZ#4#$uoKhX=^*7)qPHQs+%W5Njcd+VNLM?`*;vhaPT_0w0kdK}rmYAyE&w$WGx0 zT*kiu+Pv$qAt%ZwF^EDmm{RE@&Behj#};ZO_8RKdj*5yp(ACY^o5d<6dg};q0&yI+ zOH-dv%c7u&sdln7)sz%MjxYuWKKPUA^%qCSV5u6bsL0EQo4jI|zZkM)z!b8y=NP2N z40os|TtUpF^acJZqXLIn8VFcS;{#j*vZ_LP^rtrw(-K6Yy;iK>qHUm0qPtvsVzNEx_nqKlPd#)^~8 z(KD7b${dA6$pIqWc8nwPWP@8w!d5ZEJA|UicaxF2LDtj z8`(M}!CX#aLBkha%5)_t88YHgSy!fpho;6knPo3kFtj#31LmzWk+7x~FHRLFZIfux zNy}3#Dp}AY3DmcnP?uD>zj7grEjguyyF_1yL>xwjc*Go|Xiw{GV$`T>9od{5pmHbV zV+1xu%*RHNQ9@?HuWI8iDFj&Jv9Y1jFxSer<)$GUfos8{e-vr0fx^pU^uDBrKssbv z<_Rk(X<`HLeoZoS9BLoXpsIc`W%yO47D#YCmT~CBOx8xWNs(3T{HhRF3d?0`jrAEa zM!2dAX5k`MHZ*SIGZTTSOVvAVRfb$-*C0=!720^sII6SqlW-|vYip}2YZyRi#)ZH$ zXwY*xhx}!~0eF{|4Xg0De4!^q+R9O38PRqcq>}?T1{>!wSSOGTbSN~)1f>9%C8A~_ z=GuLN`IjNmNUE0zU)pLnIDpZi>_h|-j|-q zHCgTZu``I2fHf9_0wZMLjRH)1l#&%gO22$&v@xc`+eN1?OrWm`)kw@fIA>bLh6zm?u=>zS&otAOR+DAPuWL`BAAXa<2kRlT;S9sx;Ixq1nhJkK0DksKH_U<;(=5y`;Awy}9_q?sR> zv4-1#WLOGD=6=Fw2DOlMJa;+pAXyp_L5`-Be{((ubbe5Rd$CDGNwXtTA}1|-r!Cn? zPB2drfIva8Tc#OB3UsMBDS>wiZ|a1fh03VxfEb$BfSnv!i0~2%2D2+tb`^NByyc8` z1ZOzJIB&}Vq=$u(p-|rzo8LG>?U^soYT$F9q2!4PZazTv)}hj%I+NX;Ahnem-=%93 z*jKsc=*R!dupDS?g7-fA=-V7Ls>EdYoKHHHasrOE^n1+->4Dx41n*79p1Kwnbu8Dr S-byWDrWv)y;egc-%ePE;yKMjf literal 0 HcmV?d00001 diff --git a/test-vectors/mlkem-recipient.pqfe b/test-vectors/mlkem-recipient.pqfe new file mode 100644 index 0000000000000000000000000000000000000000..bbb323df8068f1ca7b4f9c8a61acb2117fe52e0f GIT binary patch literal 1265 zcmVJe*pwQk;{VBFMlfRFa7{)WhKJU#2lNCc(l;p zc8I!LZ|d{2cCPm%bOI0wbg@m?FxX0U}DAq8T)-4NgS#!)L=zt&bH|z7ySv$ z1JhJj5YqnBsXohEneOHxE)5@cQFMAmdi~U>gQk0*itk;~Gnx(K`=98>n3uQ{gR_6B z9NXVNau&2+o1v_TG$|{1_oN51gFWSy$%#@o%petLY|%I{Y142LX$&*jbuRkPth46I zG}nyxs4;SNVU{S+*SB$di?z!a>vytf1R4MYk&mv!tF;ZOf!Tsbvp)3tx-dsGatn)Y zN#g>-9&8G2&aW0Y^LIE;!l40|?iw2F!v=>Wb~>7yY~+$Vhw=+GO*WOv0_478+jaP3vQ$+O&%9esIv$`=`RU*hJKlJXo@ zscJ9JZ15a83SHXl1LmG*sHG}TvO2N_K}h(gyXv3G)d3S2e@^eaOZ*xD$$1E^9p*Ujn}m7sREIT-PQ3bas3N<#EG<-UcB{1U8!^326Pc3J2kTXMe1M6f&G5T$%T<;Q+Q&Mehc;U-x z!b%xd?D}sEx3x^eSG&{Pqqb2~AdIe0Aquh@X;x`_(5f2B4=j7u)ea{+8dTL6F)oLp z9KJ42=-jMH!AqSS8;TY0A%b(mdFpIhkc43ZKYxmr#QR=t)zdA_2*DD(S?}ti zGg+gEIg#WbfWSVrarfHTrI>q?N^ywR4R3_X$kr2>@?_skRNdE4U=vZ+ksL(h(eW8c zDWG6tFgaz}GUF>=b}AxOJK?Y(EZuzkD8Jf&nG_JHDwo}%eRkd5k8gf?KgDcj_)#S3 zKq#5er|o^;V3Fgc2r^*!!rC2?SD%%zmWF8+4dK*`7&N*-E8{9quV`*8xzGcg%4C+x zo)YtuCrmQpP+GFZ5c6viL~z$DVYzpht7T^3326r)PJ;7!hjA-_4lA`&fUD3gWf%b- zMg#Q;9lK%6F>W6)1cwCstmusF#4~sB)EGAq^+#${-;Gv*SzK<#<>#;}K2%N%Vdxf{ zm@}cHqcZBFy}uW_)X9{=9c{Qax#foPqTTA)bf3oO?J70y97VtN@you&9f)`u1g ziE6wV3b$2H>xM26c+$bRLQg;q(() => + new PqHybridDecryptor().DecryptBytesAsync(container, mallory.PrivateKey)); + var bodyTamper = await Assert.ThrowsAsync(() => + new PqHybridDecryptor().DecryptBytesAsync(tamperedBody, alice.PrivateKey)); + + Assert.Equal(wrongKey.Message, bodyTamper.Message); + Assert.Null(wrongKey.InnerException); + Assert.Null(bodyTamper.InnerException); + } + [Fact] public async Task Every_header_bit_flip_fails_closed_with_a_typed_exception() { diff --git a/tests/PostQuantum.FileEncryption.Tests/RecipientKnownAnswerVectorTests.cs b/tests/PostQuantum.FileEncryption.Tests/RecipientKnownAnswerVectorTests.cs new file mode 100644 index 0000000..72fc2b3 --- /dev/null +++ b/tests/PostQuantum.FileEncryption.Tests/RecipientKnownAnswerVectorTests.cs @@ -0,0 +1,79 @@ +using System.Text; +using Xunit; + +namespace PostQuantum.FileEncryption.Tests; + +///

+/// Pins the deprecated inline ML-KEM-768 recipient path (KeySource 2) with a committed +/// decrypt-only vector (docs/TEST-VECTORS.md, Vector 9) — the third key-establishment path, +/// previously pinned by nothing: its randomized round-trip tests self-skip on hosts without +/// platform ML-KEM, so a self-consistent serialization or derivation regression would have +/// passed CI everywhere. Encryption is randomized (a fresh KEM encapsulation per container), +/// so the vector is decrypt-only. Generated once on a Linux host with OpenSSL 3.5 (platform +/// ML-KEM) and frozen; the key pair was generated solely for this vector and protects nothing. +/// The artifact bytes themselves are hash-pinned on every host by . +/// +public sealed class RecipientKnownAnswerVectorTests +{ + private const string ExpectedPlaintext = + "PostQuantum.FileEncryption inline ML-KEM-768 recipient known-answer vector."; + + [Fact] + public async Task Pinned_inline_mlkem_recipient_container_decrypts() + { + if (!PqKeyPair.IsSupported) + { + return; // platform-gated; the artifacts stay hash-pinned everywhere regardless + } + + (byte[] container, PqRecipientPrivateKey privateKey) = await LoadVectorAsync(); + using (privateKey) + { + using var output = new MemoryStream(); + await new PqFileDecryptor().DecryptAsync( + new MemoryStream(container, writable: false), output, privateKey); + + Assert.Equal(ExpectedPlaintext, Encoding.UTF8.GetString(output.ToArray())); + } + } + + [Fact] + public async Task Pinned_inline_mlkem_recipient_container_rejects_tampering() + { + if (!PqKeyPair.IsSupported) + { + return; + } + + (byte[] container, PqRecipientPrivateKey privateKey) = await LoadVectorAsync(); + using (privateKey) + { + container[^1] ^= 0x01; // final tag byte + + using var output = new MemoryStream(); + await Assert.ThrowsAsync(() => + new PqFileDecryptor().DecryptAsync( + new MemoryStream(container, writable: false), output, privateKey)); + } + } + + private static async Task<(byte[] container, PqRecipientPrivateKey privateKey)> LoadVectorAsync() + { + string dir = Path.Combine(FindRepositoryRoot(), "test-vectors"); + byte[] container = await File.ReadAllBytesAsync(Path.Combine(dir, "mlkem-recipient.pqfe")); + byte[] keyBytes = await File.ReadAllBytesAsync(Path.Combine(dir, "mlkem-recipient.key")); + return (container, PqRecipientPrivateKey.Import(keyBytes)); + } + + private static string FindRepositoryRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "test-vectors"))) + { + dir = dir.Parent; + } + return dir?.FullName + ?? throw new InvalidOperationException( + $"No 'test-vectors' directory found above {AppContext.BaseDirectory}."); + } +} diff --git a/tests/PostQuantum.FileEncryption.Tests/VectorArtifactTests.cs b/tests/PostQuantum.FileEncryption.Tests/VectorArtifactTests.cs index 2b902b0..4e12792 100644 --- a/tests/PostQuantum.FileEncryption.Tests/VectorArtifactTests.cs +++ b/tests/PostQuantum.FileEncryption.Tests/VectorArtifactTests.cs @@ -18,6 +18,8 @@ public sealed class VectorArtifactTests [InlineData("passphrase-pbkdf2-multichunk.pqfe", "008543AB899A427A33100F1B865B54C5EF0426834B8E7B696EEFFC9B97EA886A")] [InlineData("keyfile.pqkf", "EEDA08E328B028E69F87145642C7898C72BE83E410EBFD595F0B2B50FD9BFB38")] [InlineData("hybrid-recipient.pqfe", "A16FF8DB3DAD6A50D9A81CEE5A97CE26D875C8DCE80A00C93DD7516F080D31DE")] + [InlineData("mlkem-recipient.pqfe", "02D3614753172B9EB9690CB35325794FAC5E9A67FAF5F81377B708725ED00503")] + [InlineData("mlkem-recipient.key", "FF8599053E453E11AAD3149736C7094484A39CF8D982AB9AB285956889CA5444")] public void Committed_vector_artifact_is_byte_identical(string fileName, string expectedSha256) { string path = Path.Combine(FindRepositoryRoot(), "test-vectors", fileName);