From 8bea20813db33a57f3521c5a5e205a8a3508d35d Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Fri, 4 Sep 2026 06:35:38 -0400 Subject: [PATCH 1/2] =?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/2] 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); + } +}