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 0000000..fd67096 Binary files /dev/null and b/test-vectors/negative/argon2-iterations-out-of-range.pqfe differ 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 0000000..90ed04e Binary files /dev/null and b/test-vectors/negative/argon2-memory-out-of-range.pqfe differ diff --git a/test-vectors/negative/argon2-parallelism-zero.pqfe b/test-vectors/negative/argon2-parallelism-zero.pqfe new file mode 100644 index 0000000..ee3a3a9 Binary files /dev/null and b/test-vectors/negative/argon2-parallelism-zero.pqfe differ diff --git a/test-vectors/negative/cross-container-transplant.pqfe b/test-vectors/negative/cross-container-transplant.pqfe new file mode 100644 index 0000000..eb8b72d Binary files /dev/null and b/test-vectors/negative/cross-container-transplant.pqfe differ diff --git a/test-vectors/negative/final-frame-dropped.pqfe b/test-vectors/negative/final-frame-dropped.pqfe new file mode 100644 index 0000000..e502af1 Binary files /dev/null and b/test-vectors/negative/final-frame-dropped.pqfe differ diff --git a/test-vectors/negative/frame-swap.pqfe b/test-vectors/negative/frame-swap.pqfe new file mode 100644 index 0000000..5adfb05 Binary files /dev/null and b/test-vectors/negative/frame-swap.pqfe differ diff --git a/test-vectors/negative/salt-too-short.pqfe b/test-vectors/negative/salt-too-short.pqfe new file mode 100644 index 0000000..6ca1059 Binary files /dev/null and b/test-vectors/negative/salt-too-short.pqfe differ 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 0000000..d253310 Binary files /dev/null and b/test-vectors/negative/truncated-at-frame-boundary.pqfe differ diff --git a/test-vectors/passphrase-pbkdf2-multichunk.pqfe b/test-vectors/passphrase-pbkdf2-multichunk.pqfe new file mode 100644 index 0000000..f2d2440 Binary files /dev/null and b/test-vectors/passphrase-pbkdf2-multichunk.pqfe differ 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); + } +}