diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 9c1d54b..e0b7375 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -44,8 +44,9 @@ jobs: # corpus (fuzz/corpus, cached above) is gitignored. -n keeps any # coverage-accumulated inputs restored from cache. run: | - mkdir -p fuzz/corpus/decrypt + mkdir -p fuzz/corpus/decrypt fuzz/corpus/decrypt_hybrid cp -n fuzz/seed-corpus/*.pqfe fuzz/corpus/decrypt/ 2>/dev/null || true + cp -n fuzz/seed-corpus/*.pqfe fuzz/corpus/decrypt_hybrid/ 2>/dev/null || true - name: Fuzz the decrypt parser working-directory: samples/pqfe-wasm @@ -53,6 +54,14 @@ jobs: DURATION="${{ github.event.inputs.duration_seconds || '300' }}" cargo +nightly fuzz run decrypt -- -max_total_time="$DURATION" -rss_limit_mb=4096 -timeout=20 + - name: Fuzz the hybrid decrypt parser + working-directory: samples/pqfe-wasm + # The recipient-block parsers (KeySource 3/4) are unreachable from the passphrase + # target; this one drives decrypt_bytes_hybrid with the pinned conformance key. + run: | + DURATION="${{ github.event.inputs.duration_seconds || '300' }}" + cargo +nightly fuzz run decrypt_hybrid -- -max_total_time="$DURATION" -rss_limit_mb=4096 -timeout=20 + - name: Upload crash artifacts if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index fc3182f..25fa720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,68 @@ and the `.pqfe` v2 container format is frozen for the entire `1.x` line. ## [Unreleased] +### Fixed + +Sixteen defects found by an adversarially verified multi-agent bug hunt (every finding +independently re-traced before fixing; none affects any on-disk byte or reader acceptance): + +- **Key hygiene:** the container engine's chunk staging buffers are now zeroed in the same + `finally` as the content key — for PQKF traffic those buffers held a full private key on + the managed heap after export/import. `LocalKekContentKeyProvider`'s constructor zeroes its + cloned KEK if `AesGcm` construction throws (the half-built object could never be disposed). +- **Fail-closed exception contract:** an empty byte-passphrase against an Argon2id container + no longer escapes as Konscious's raw `ArgumentException` (the exception family was steered + by an unauthenticated header byte); `PqKeyFileFormat.Decrypt` no longer converts + `PlatformNotSupportedException` into "wrong kind of key file" on AES-GCM-less platforms; + near-`Array.MaxLength` inputs to the bytes APIs no longer throw a raw + `ArgumentOutOfRangeException` from an internal capacity hint (core + Hybrid); an oversized + provider `wrapInfo` is rejected with the intended message instead of an internal-parameter + `ArgumentException`. +- **Data-loss guards:** `PqSigner.SignFileAsync` (and `pqfe sign --signature`) refuse a + signature path equal to the input or key file — previously that silently replaced the + signed file (or the private key) with the signature and reported success; the WebUpload + Keygen sample opens both outputs `CreateNew` (an existing key pair is a hard error — an + overwrite would permanently orphan every prior upload), writes the private half first with + `0600` on Unix, and cleans up on partial failure; the CLI's overwrite refusal now claims + the output path atomically so it holds across the interactive passphrase prompt; a second + Ctrl+C now always terminates the CLI even when a redirected-stdin read cannot observe + cancellation. +- **Azure provider:** a null-`KeyId` (local `JsonWebKey`) client no longer rejects every + legitimate container with a nonsense pin message — nor accepts a hostile `/`-prefixed + recorded id via accidental wildcard (`null + "/"`); the header-derived recorded key id is + control-character-sanitized before it reaches an exception message. +- **Rust core:** `encrypt_bytes_with` validates its parameters (a zero `chunk_size` looped + forever; a wrong-length nonce prefix panicked; an over-long salt silently produced a + container no reader can decrypt); a new `decrypt_hybrid` fuzz target covers the + KeySource-3/4 recipient-block parsers the passphrase target cannot reach. +- **Samples/tooling:** the Backup quickstart no longer aborts the whole run with a raw stack + trace on a locked/unreadable file; the pqfe-web page no longer hangs at "Encrypting…" + forever if the worker crashes mid-operation; the docs-consistency link checker handles + markdown link titles and URL-encoded targets. + +### Changed (behavior) + +- **All in-library key-dependent decryption failures are now fully uniform** — one exception + type, one identical message, no inner exception — across *stages* as well as causes: wrong + passphrase, wrong hybrid/inline-ML-KEM recipient key, wrong local KEK, tampered wrap, and + tampered body are indistinguishable to a caller. Previously the unwrap stage carried + distinct messages ("this file is not encrypted to this key…"), which let a service exposing + raw errors act as a key-possession oracle (externally probed and confirmed). Callers who + matched on the old message text must match on `PqDecryptionException` instead. Cloud-KMS + providers keep their uniform provider-specific operational messages (see KNOWN-GAPS.md). +- **`pqfe decrypt` gains `--untrusted`** (maps to `PqDecryptionLimits.Untrusted`), and the + Blazor demo decrypts anonymous uploads under `Untrusted` limits — the library's own + documented anti-pattern, now fixed in its own samples. + ### Added +- **Vector 9 — inline ML-KEM-768 recipient known-answer vector** (`test-vectors/ + mlkem-recipient.pqfe` + `.key`, decrypt-only, hash-pinned everywhere): the third + key-establishment path, previously pinned by nothing because its round-trip tests self-skip + without platform ML-KEM. Generated and round-trip-verified on a Linux host with OpenSSL 3.5, + where the full suite (RecipientTests included) also ran green — their first known execution. +- **Cross-stage no-oracle test** pinning that a wrong recipient key and a tampered body yield + byte-identical `PqDecryptionException`s with null inner exceptions. - **Nine new conformance-corpus vectors, run by both implementations.** A deterministic two-chunk positive (`pos-passphrase-pbkdf2-multichunk`) plus eight negatives: the Argon2id cost/salt bounds (`neg-argon2-memory-out-of-range`, `neg-argon2-iterations-out-of-range`, diff --git a/KNOWN-GAPS.md b/KNOWN-GAPS.md index 6717a0b..14a2b91 100644 --- a/KNOWN-GAPS.md +++ b/KNOWN-GAPS.md @@ -122,19 +122,18 @@ Last reviewed against: **`1.7.1`**. See [ROADMAP.md](ROADMAP.md) for the forward - **Passphrases are still `string` on the convenience overloads.** The zeroable byte overloads exist, but the `string` overloads remain for ergonomics and cannot zero the caller's `string`. -- **The failure *stage* is distinguishable across key-establishment and body authentication.** - Within each stage messages are uniform and pinned by tests (wrong passphrase vs. tampered - bytes is one identical message), but the stages differ: a hybrid/provider key that fails to - *unwrap* the content key throws "not encrypted to this key…" while a successfully unwrapped - container whose *body* fails authentication throws the generic tamper message (with a - different inner exception). An external probe confirmed the two are programmatically - distinguishable. That tells an attacker submitting a crafted container to a service whether - the service's key could open it — a key-possession/membership signal, though never a - plaintext or key-recovery oracle. Unifying the public message and inner-exception policy - across stages is `1.x`-safe (exception text is not part of the frozen format) but changes - observable behavior callers may match on, so it is deliberately a considered decision rather - than a quiet patch; until then, services should log raw exception detail privately and - return a uniform error to untrusted callers. +- **In-library key-dependent failures are now stage-uniform; cloud-provider unwrap errors are + not.** Every key-dependent failure raised by the library itself — wrong passphrase, wrong + hybrid recipient key (KeySource 3/4), wrong inline ML-KEM key (KeySource 2), wrong local + KEK, tampered wrap, tampered body — throws `PqDecryptionException` with one identical + message and **no inner exception**, so which *stage* rejected the input is not observable + (previously, unwrap-stage failures carried distinct messages — a key-possession signal for + services exposing raw errors; an external probe confirmed it, and it was unified in + response). The residual: the Aws/AzureKeyVault/Gcp providers surface their own uniform but + provider-specific messages for *remote* unwrap failures, so a KeySource-5 deployment using a + cloud KMS still distinguishes "KMS refused the wrap" from "body failed authentication". + Services in that position should return a uniform error to untrusted callers and keep raw + detail in private logs. ### Dependency assurance @@ -315,8 +314,13 @@ Last reviewed against: **`1.7.1`**. See [ROADMAP.md](ROADMAP.md) for the forward executions) and scheduled nightly in CI with a cached corpus (`.github/workflows/fuzz.yml`). OSS-Fuzz integration files are ready (`oss-fuzz/`) but upstream onboarding is not yet done, and the accumulated corpora are still small. See [docs/FUZZING.md](docs/FUZZING.md). -- **Recipient round-trip is not exercised on this CI host**, which lacks platform ML-KEM; those - tests self-skip there. The capability gating *is* tested everywhere. +- **Recipient round-trip tests self-skip on hosts without platform ML-KEM** (including the + current CI runners). The path is now pinned wherever it *can* run: a committed decrypt-only + known-answer vector (`test-vectors/mlkem-recipient.pqfe`, Vector 9 in + [docs/TEST-VECTORS.md](docs/TEST-VECTORS.md)) exercises the frozen KeySource-2 bytes on any + host where `PqKeyPair.IsSupported`, its artifact hashes are pinned on every host, and the + full suite (RecipientTests included) has run green on a Linux host with OpenSSL 3.5. The + capability gating *is* tested everywhere. - **NuGet author-signing** requires a code-signing certificate (not configured); nuget.org applies repository signatures on publish. The release workflow produces an SBOM and a provenance attestation. - **Two target frameworks.** `net8.0` and `net10.0`. The public API is identical on both, with diff --git a/SECURITY.md b/SECURITY.md index 61baf6b..a8c521e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -55,13 +55,13 @@ report until a fix is available and coordinated. `PqDecryptionException`, and every *key-dependent* failure (wrong passphrase vs. tampered ciphertext) carries one identical message — the library never distinguishes "wrong key" from "tampered data" at the public surface, and tests pin those messages byte-identical. + This holds across *stages* as well: a recipient/KEK unwrap failure and a body-authentication + failure throw the identical message with no distinguishing inner exception, so a service + exposing raw error text does not reveal whether its key could unwrap a submitted container. Structural failures that an attacker can already compute from the ciphertext alone - (truncation, corrupt framing) carry distinct but key-independent diagnostics. One - distinction *is* key-dependent: which stage failed. A recipient/provider key that cannot - unwrap the content key reports differently from a body that fails chunk authentication, so - a service that exposes raw error details can reveal whether its key could open a submitted - container — see [KNOWN-GAPS.md](KNOWN-GAPS.md) before surfacing exception text across a - trust boundary. + (truncation, corrupt framing) carry distinct but key-independent diagnostics, and cloud-KMS + providers surface their own provider-specific operational errors — see + [KNOWN-GAPS.md](KNOWN-GAPS.md) before surfacing exception text across a trust boundary. - **No partial output on failure.** File APIs stage every byte to a sibling temp file and only `File.Move` it into place on full success. Stream callers can opt into the same all-or-nothing guarantee via `DecryptAtomicAsync`. diff --git a/docs/FUZZING.md b/docs/FUZZING.md index 3347d81..8c694eb 100644 --- a/docs/FUZZING.md +++ b/docs/FUZZING.md @@ -88,6 +88,12 @@ mkdir -p corpus && cp fuzz/PostQuantum.FileEncryption.Fuzz/seed-corpus/*.pqfe \ > `ParserBoundaryTests` and the committed negative vectors instead. The CI jobs also pass > `-timeout=20` per input and upload `timeout-*`/`oom-*` reproducers alongside `crash-*`. +The Rust side has **two** targets: `decrypt` (passphrase parser) and `decrypt_hybrid`, which +drives `decrypt_bytes_hybrid` with the pinned multi-recipient conformance key so the +KeySource-3/4 recipient-block parsers — unreachable from the passphrase target — get +coverage-guided fuzzing too, including the unwrap-success branch (its matching container is +seeded into the corpus). + ## Scheduled CI `.github/workflows/fuzz.yml` runs both targets nightly (and on demand via *Run workflow*), caches diff --git a/docs/TEST-VECTORS.md b/docs/TEST-VECTORS.md index de25936..dd9693b 100644 --- a/docs/TEST-VECTORS.md +++ b/docs/TEST-VECTORS.md @@ -216,6 +216,29 @@ the two test suites above (`PrivateKeyVector` / `ContainerVector` and `HYBRID8_P --- +## Vector 9 — inline ML-KEM-768 recipient (KeySource 2, deprecated, decrypt-only) + +Pins the deprecated inline recipient path byte-exactly: the KeySource-2 wrap layout +(`KemId ‖ C ‖ KemCiphertext ‖ WrapNonce ‖ WrapTag ‖ WrappedKey`), the ML-KEM-768 +decapsulation, the `HKDF-SHA256` KEK derivation, and the AES-256-GCM key unwrap — the one +key-establishment path no other vector covered (its randomized round-trip tests self-skip on +hosts without platform ML-KEM). Encryption is randomized, so the vector is decrypt-only. +Generated once on a Linux host with OpenSSL 3.5 (platform ML-KEM) and frozen. The artifacts +are committed files rather than inline Base64, hash-pinned by `VectorArtifactTests` and +`test-vectors/SHA256SUMS`; the decrypt is exercised by +`tests/.../RecipientKnownAnswerVectorTests.cs` wherever `PqKeyPair.IsSupported`. The key pair +was generated solely for this vector and protects nothing. The Rust core does not implement +this mode, so the vector is not in the cross-implementation manifest. + +| Field | Value | +| --- | --- | +| Key source | `2` (inline ML-KEM-768 recipient, deprecated `PQFE001`/`PQFE002`) | +| Container | [`test-vectors/mlkem-recipient.pqfe`](../test-vectors/mlkem-recipient.pqfe), SHA-256 `02d3614753172b9eb9690cb35325794fac5e9a67faf5f81377b708725ed00503` | +| Recipient private key | [`test-vectors/mlkem-recipient.key`](../test-vectors/mlkem-recipient.key) (`PqRecipientPrivateKey.Export()`, 2,400 bytes), SHA-256 `ff8599053e453e11aad3149736c7094484a39cf8d982ab9ab285956889ca5444` | +| Expected plaintext (UTF-8) | `PostQuantum.FileEncryption inline ML-KEM-768 recipient known-answer vector.` | + +--- + ## How to verify ```bash diff --git a/samples/PostQuantum.FileEncryption.Demo/Components/Pages/Home.razor b/samples/PostQuantum.FileEncryption.Demo/Components/Pages/Home.razor index f8d9fb7..944614c 100644 --- a/samples/PostQuantum.FileEncryption.Demo/Components/Pages/Home.razor +++ b/samples/PostQuantum.FileEncryption.Demo/Components/Pages/Home.razor @@ -142,7 +142,10 @@ { using var input = await ReadFileAsync(_decryptFile); using var output = new MemoryStream(); - await new PqFileDecryptor().DecryptAsync(input, output, _decryptPassphrase, progress, ct); + // Untrusted limits: uploads are anonymous, and a hostile ~200-byte header could + // otherwise legally demand 2 GiB of Argon2id memory per request (ANTI-PATTERNS.md). + await new PqFileDecryptor(PqDecryptionLimits.Untrusted) + .DecryptAsync(input, output, _decryptPassphrase, progress, ct); string outName = SuggestDecryptedName(_decryptFile.Name); await DownloadAsync(outName, output.ToArray()); return $"Decrypted {_decryptFile.Name} → {outName}."; diff --git a/samples/Pqfe.Cli/Program.cs b/samples/Pqfe.Cli/Program.cs index ec747aa..edfefb9 100644 --- a/samples/Pqfe.Cli/Program.cs +++ b/samples/Pqfe.Cli/Program.cs @@ -50,7 +50,10 @@ private static async Task Main(string[] args) using var cts = new CancellationTokenSource(); ConsoleCancelEventHandler onCancel = (_, e) => { - e.Cancel = true; + // First Ctrl+C: cooperative (cleanup runs). A second Ctrl+C is allowed to + // terminate the process, so a synchronous read the token cannot reach (e.g. a + // redirected-stdin passphrase read on a silent pipe) can always be escaped. + e.Cancel = !cts.IsCancellationRequested; // A Ctrl+C racing process exit can fire after the CTS is disposed; swallowing the // ObjectDisposedException here beats crashing on the very keystroke we intercepted. try { cts.Cancel(); } @@ -113,25 +116,48 @@ private static async Task EncryptAsync(string[] rest, CancellationToken can { if (!TryParsePaths(rest, out string? input, out string? output, out var flags)) return Fail("usage: pqfe encrypt [--argon2id] [--passphrase-env VAR] [--force]", ExitUsage); + if (flags.Untrusted) + return Fail("--untrusted applies to decrypt only.", ExitUsage); - if (!flags.Force && File.Exists(output)) - return Fail($"'{output}' already exists; refusing to overwrite (use --force).", ExitCantCreate); - - var options = new PqEncryptionOptions + // Claim the output path up front (not just File.Exists): the refusal contract must + // hold across the interactive passphrase prompt, which can stay open indefinitely — + // a bare existence check would silently clobber a file created during the prompt. + bool claimed = false; + if (!flags.Force) { - Kdf = flags.UseArgon2id ? PqKdf.Argon2id : PqKdf.Pbkdf2HmacSha256, - }; + switch (TryClaimOutput(output)) + { + case ClaimOutcome.Exists: + return Fail($"'{output}' already exists; refusing to overwrite (use --force).", ExitCantCreate); + case ClaimOutcome.Claimed: + claimed = true; + break; + } + } - byte[] passphrase = ReadPassphrase(flags.PassphraseEnv, confirm: true, cancellationToken); try { - var encryptor = new PqFileEncryptor(options); - var progress = new Progress(ReportProgress); - await encryptor.EncryptFileAsync(input, output, passphrase, progress, cancellationToken).ConfigureAwait(false); + var options = new PqEncryptionOptions + { + Kdf = flags.UseArgon2id ? PqKdf.Argon2id : PqKdf.Pbkdf2HmacSha256, + }; + + byte[] passphrase = ReadPassphrase(flags.PassphraseEnv, confirm: true, cancellationToken); + try + { + var encryptor = new PqFileEncryptor(options); + var progress = new Progress(ReportProgress); + await encryptor.EncryptFileAsync(input, output, passphrase, progress, cancellationToken).ConfigureAwait(false); + } + finally + { + CryptographicOperations.ZeroMemory(passphrase); + } } - finally + catch { - CryptographicOperations.ZeroMemory(passphrase); + if (claimed) ReleaseClaimedOutput(output); + throw; } Console.Error.WriteLine($"\nEncrypted {input} -> {output}"); @@ -141,21 +167,43 @@ private static async Task EncryptAsync(string[] rest, CancellationToken can private static async Task DecryptAsync(string[] rest, CancellationToken cancellationToken) { if (!TryParsePaths(rest, out string? input, out string? output, out var flags)) - return Fail("usage: pqfe decrypt [--passphrase-env VAR] [--force]", ExitUsage); + return Fail("usage: pqfe decrypt [--untrusted] [--passphrase-env VAR] [--force]", ExitUsage); - if (!flags.Force && File.Exists(output)) - return Fail($"'{output}' already exists; refusing to overwrite (use --force).", ExitCantCreate); + // See EncryptAsync: claim the path so the refusal holds across the passphrase prompt. + bool claimed = false; + if (!flags.Force) + { + switch (TryClaimOutput(output)) + { + case ClaimOutcome.Exists: + return Fail($"'{output}' already exists; refusing to overwrite (use --force).", ExitCantCreate); + case ClaimOutcome.Claimed: + claimed = true; + break; + } + } - byte[] passphrase = ReadPassphrase(flags.PassphraseEnv, confirm: false, cancellationToken); try { - var decryptor = new PqFileDecryptor(); - var progress = new Progress(ReportProgress); - await decryptor.DecryptFileAsync(input, output, passphrase, progress, cancellationToken).ConfigureAwait(false); + byte[] passphrase = ReadPassphrase(flags.PassphraseEnv, confirm: false, cancellationToken); + try + { + // --untrusted caps the KDF cost a hostile container header may demand (a + // ~90-byte file can otherwise legally ask for 2 GiB of Argon2id memory). + var decryptor = new PqFileDecryptor( + flags.Untrusted ? PqDecryptionLimits.Untrusted : PqDecryptionLimits.Default); + var progress = new Progress(ReportProgress); + await decryptor.DecryptFileAsync(input, output, passphrase, progress, cancellationToken).ConfigureAwait(false); + } + finally + { + CryptographicOperations.ZeroMemory(passphrase); + } } - finally + catch { - CryptographicOperations.ZeroMemory(passphrase); + if (claimed) ReleaseClaimedOutput(output); + throw; } Console.Error.WriteLine($"\nDecrypted {input} -> {output}"); @@ -212,6 +260,18 @@ private static async Task SignAsync(string[] rest, CancellationToken cancel if (!TryParseSigning(rest, out string? input, out string? keyPath, out string? signaturePath, out string? passphraseEnv)) return Fail("usage: pqfe sign [--signature PATH] [--passphrase-env VAR]", ExitUsage); + // --signature pointing at the input or the key file would atomically replace that + // file with the ~3 KB signature — silent data loss reported as success. Refuse. + StringComparison pathCmp = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + string fullSignature = Path.GetFullPath(signaturePath); + if (string.Equals(fullSignature, Path.GetFullPath(input), pathCmp) + || string.Equals(fullSignature, Path.GetFullPath(keyPath), pathCmp)) + { + return Fail("--signature must not point at the input or the key file.", ExitUsage); + } + byte[] keyBytes = await File.ReadAllBytesAsync(keyPath, cancellationToken).ConfigureAwait(false); try { @@ -411,6 +471,9 @@ private static bool TryParsePaths( case "--force": flags = flags with { Force = true }; break; + case "--untrusted": + flags = flags with { Untrusted = true }; + break; default: if (a.StartsWith('-')) return false; positionals.Add(a); @@ -424,6 +487,49 @@ private static bool TryParsePaths( return true; } + private enum ClaimOutcome { Exists, Claimed, NotClaimed } + + /// + /// Atomically claims the output path with a zero-byte placeholder so the overwrite + /// refusal cannot be raced by a file appearing while the passphrase prompt is open. + /// means the path could not be probed (e.g. its + /// directory does not exist) — proceed and let the library produce its clearer error. + /// + private static ClaimOutcome TryClaimOutput(string output) + { + try + { + new FileStream(output, FileMode.CreateNew, FileAccess.Write, FileShare.None).Dispose(); + return ClaimOutcome.Claimed; + } + catch (IOException) + { + return File.Exists(output) ? ClaimOutcome.Exists : ClaimOutcome.NotClaimed; + } + catch (UnauthorizedAccessException) + { + return ClaimOutcome.NotClaimed; + } + } + + /// Removes the placeholder left by — only if it is + /// still the empty file we created (never a file someone else has since written). + private static void ReleaseClaimedOutput(string output) + { + try + { + using (var probe = new FileStream(output, FileMode.Open, FileAccess.Read, FileShare.None)) + { + if (probe.Length != 0) return; + } + File.Delete(output); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort cleanup; the real failure is already propagating. + } + } + private static byte[] ReadPassphrase(string? envVar, bool confirm, CancellationToken cancellationToken) => Encoding.UTF8.GetBytes(ReadPassphraseString(envVar, confirm, cancellationToken)); @@ -536,7 +642,7 @@ private static void PrintUsage() Usage: pqfe encrypt [--argon2id] [--passphrase-env VAR] - pqfe decrypt [--passphrase-env VAR] + pqfe decrypt [--untrusted] [--passphrase-env VAR] pqfe keygen [--encrypt [--passphrase-env VAR]] pqfe sign [--signature PATH] [--passphrase-env VAR] pqfe verify [--signature PATH] @@ -552,6 +658,9 @@ Decryption reads the KDF from the container header — no flag needed. processes and can surface in crash dumps and process inspection — scope VAR to the single invocation. --signature PATH Detached-signature path (default: + ".sig"). + --untrusted (decrypt) Enforce PqDecryptionLimits.Untrusted: tight + ceilings on the KDF cost a hostile container header can + demand. Recommended for files from untrusted sources. --encrypt (keygen) Protect the private key file with a passphrase (PQKF format: an Argon2id-hardened .pqfe container). sign detects an encrypted key file automatically and @@ -568,7 +677,7 @@ 130 interrupted (Ctrl+C). """); } - private readonly record struct Flags(bool UseArgon2id, string? PassphraseEnv, bool Force); + private readonly record struct Flags(bool UseArgon2id, string? PassphraseEnv, bool Force, bool Untrusted); /// /// A usage-level failure raised deep in a helper. Main maps it to exit 64 after every diff --git a/samples/pqfe-wasm/fuzz/Cargo.toml b/samples/pqfe-wasm/fuzz/Cargo.toml index 671a72c..9377ba7 100644 --- a/samples/pqfe-wasm/fuzz/Cargo.toml +++ b/samples/pqfe-wasm/fuzz/Cargo.toml @@ -23,3 +23,10 @@ path = "fuzz_targets/decrypt.rs" test = false doc = false bench = false + +[[bin]] +name = "decrypt_hybrid" +path = "fuzz_targets/decrypt_hybrid.rs" +test = false +doc = false +bench = false diff --git a/samples/pqfe-wasm/fuzz/fuzz_targets/decrypt_hybrid.rs b/samples/pqfe-wasm/fuzz/fuzz_targets/decrypt_hybrid.rs new file mode 100644 index 0000000..d455740 --- /dev/null +++ b/samples/pqfe-wasm/fuzz/fuzz_targets/decrypt_hybrid.rs @@ -0,0 +1,17 @@ +#![no_main] +// Fuzzes the hybrid (KeySource 3/4) decrypt path. The passphrase target cannot reach it: +// decrypt_bytes returns Unsupported for these key sources right after the header parse, so +// try_unwrap_block / unwrap_multi — manual offset arithmetic over hostile bytes — had zero +// coverage-guided fuzzing. Using the pinned multi-recipient conformance key (its container is +// in the seed corpus) lets the fuzzer reach the unwrap-SUCCESS branch and the post-unwrap +// frame loop, not just the parse-and-reject paths. +use libfuzzer_sys::fuzz_target; + +const PRIVATE_KEY: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../test-vectors/lenient/multi-recipient-trailing.key" +)); + +fuzz_target!(|data: &[u8]| { + let _ = pqfe_wasm::decrypt_bytes_hybrid(data, PRIVATE_KEY); +}); diff --git a/samples/pqfe-wasm/fuzz/seed-corpus/hybrid-recipient.pqfe b/samples/pqfe-wasm/fuzz/seed-corpus/hybrid-recipient.pqfe new file mode 100644 index 0000000..65a2895 Binary files /dev/null and b/samples/pqfe-wasm/fuzz/seed-corpus/hybrid-recipient.pqfe differ diff --git a/samples/pqfe-wasm/fuzz/seed-corpus/multi-recipient-trailing.pqfe b/samples/pqfe-wasm/fuzz/seed-corpus/multi-recipient-trailing.pqfe new file mode 100644 index 0000000..4b18a73 Binary files /dev/null and b/samples/pqfe-wasm/fuzz/seed-corpus/multi-recipient-trailing.pqfe differ diff --git a/samples/pqfe-wasm/src/lib.rs b/samples/pqfe-wasm/src/lib.rs index f97abfe..f80da26 100644 --- a/samples/pqfe-wasm/src/lib.rs +++ b/samples/pqfe-wasm/src/lib.rs @@ -174,6 +174,28 @@ pub fn encrypt_bytes_with( iterations: u32, chunk_size: u32, ) -> Vec { + // This test-facing writer must enforce the same parameter ranges as the .NET writer's + // PqEncryptionOptions.Validate: without these, chunk_size = 0 loops forever appending + // empty frames, a nonce prefix that is not 4 bytes panics mid-write, and a salt longer + // than 255 bytes silently truncates its length byte — producing a container no + // conforming reader can ever decrypt (silent data loss for a caller who kept only the + // ciphertext). + assert!( + (MIN_SALT_LEN..=255).contains(&salt.len()), + "salt must be 8..=255 bytes" + ); + assert!( + nonce_prefix.len() == NONCE_PREFIX_LEN, + "nonce prefix must be exactly 4 bytes" + ); + assert!( + (MIN_PBKDF2_ITERS..=MAX_PBKDF2_ITERS).contains(&iterations), + "iterations out of the format range" + ); + assert!( + (MIN_CHUNK..=MAX_CHUNK).contains(&chunk_size), + "chunk size out of the format range" + ); let key = derive_pbkdf2(passphrase, salt, iterations); // KeyParams (passphrase / PBKDF2): KdfId | SaltLen | Salt | Iterations(u32 BE) diff --git a/samples/pqfe-web/app.js b/samples/pqfe-web/app.js index 6f19157..4abbf2a 100644 --- a/samples/pqfe-web/app.js +++ b/samples/pqfe-web/app.js @@ -54,7 +54,15 @@ function createWorkerBackend() { pending.delete(m.id); m.ok ? p.res(m.result) : p.rej(new Error(m.error)); }; - worker.onerror = (e) => { clearTimeout(timer); reject(new Error(e.message || 'worker error')); }; + worker.onerror = (e) => { + clearTimeout(timer); + const err = new Error(e.message || 'worker error'); + reject(err); // no-op if init already resolved + // A worker crash after init must not strand in-flight operations: reject them all + // so the UI shows the error instead of hanging at "Encrypting…" forever. + for (const p of pending.values()) p.rej(err); + pending.clear(); + }; }); } diff --git a/samples/quickstarts/Pqfe.QuickStart.Backup/Program.cs b/samples/quickstarts/Pqfe.QuickStart.Backup/Program.cs index 209d8f5..6794ce4 100644 --- a/samples/quickstarts/Pqfe.QuickStart.Backup/Program.cs +++ b/samples/quickstarts/Pqfe.QuickStart.Backup/Program.cs @@ -71,6 +71,13 @@ Console.Error.WriteLine($"FAILED {name}: wrong passphrase, or the file was altered"); failures++; } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A locked, unreadable, or concurrently deleted file must not abort the whole run + // with a raw stack trace — report it like the other per-file failures and move on. + Console.Error.WriteLine($"FAILED {name}: {ex.Message}"); + failures++; + } } } catch (OperationCanceledException) diff --git a/samples/quickstarts/Pqfe.QuickStart.WebUpload.Keygen/Program.cs b/samples/quickstarts/Pqfe.QuickStart.WebUpload.Keygen/Program.cs index 8df5d8e..d71c75b 100644 --- a/samples/quickstarts/Pqfe.QuickStart.WebUpload.Keygen/Program.cs +++ b/samples/quickstarts/Pqfe.QuickStart.WebUpload.Keygen/Program.cs @@ -11,6 +11,12 @@ // The private-key file is passphrase-protected. Provide the passphrase via the PQFE_PASS // environment variable, or you will be prompted for it. The private key is written ONLY in the // encrypted PQKF form (ExportEncrypted), never as raw Export() bytes. +// +// Both outputs are opened CreateNew: overwriting an existing key pair would permanently orphan +// every container already encrypted to the old public key, so an existing file is a hard error, +// never a silent replace. The private key is written first (owner-only permissions on Unix); if +// the public half then fails to write, the private file is removed so no mismatched half-pair +// is left behind. using System.Security.Cryptography; using PostQuantum.FileEncryption.Hybrid; @@ -36,10 +42,27 @@ await Console.Error.WriteLineAsync( try { using var keyPair = PqHybridKeyPair.Generate(); - await File.WriteAllBytesAsync(publicOut, keyPair.PublicKey.Export()); // ExportEncrypted (not Export): the private key is written only as an authenticated, // Argon2id-hardened PQKF file that fails closed on a wrong passphrase or any tampering. - await File.WriteAllBytesAsync(privateOut, keyPair.PrivateKey.ExportEncrypted(passphrase)); + await WriteNewFileAsync(privateOut, keyPair.PrivateKey.ExportEncrypted(passphrase), ownerOnly: true); + try + { + await WriteNewFileAsync(publicOut, keyPair.PublicKey.Export(), ownerOnly: false); + } + catch + { + // Never leave a private key whose public half failed to materialize. + try { File.Delete(privateOut); } catch (IOException) { } + throw; + } +} +catch (IOException ex) +{ + await Console.Error.WriteLineAsync( + $"error: {ex.Message}\nRefusing to overwrite an existing key file — replacing a key pair " + + "would permanently orphan every upload already encrypted to the old public key. " + + "Move the existing files aside first if you really mean to rotate."); + return 73; // sysexits.h EX_CANTCREAT } finally { @@ -50,6 +73,22 @@ await Console.Error.WriteLineAsync( Console.WriteLine($"Wrote private key -> {privateOut} (keep secret; never on the web server)"); return 0; +static async Task WriteNewFileAsync(string path, byte[] bytes, bool ownerOnly) +{ + var options = new FileStreamOptions + { + Mode = FileMode.CreateNew, // an existing file is an error, never a silent overwrite + Access = FileAccess.Write, + Share = FileShare.None, + }; + if (ownerOnly && !OperatingSystem.IsWindows()) + { + options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; // 0600, like ssh-keygen + } + await using var stream = new FileStream(path, options); + await stream.WriteAsync(bytes); +} + static char[]? ReadPassphrase() { string? fromEnv = Environment.GetEnvironmentVariable("PQFE_PASS"); diff --git a/scripts/check-docs-consistency.sh b/scripts/check-docs-consistency.sh index fe51af8..d75afca 100644 --- a/scripts/check-docs-consistency.sh +++ b/scripts/check-docs-consistency.sh @@ -131,7 +131,7 @@ while IFS= read -r mdfile; do echo "::error file=$mdfile,line=$lineno::dead internal link '$target' (no file at '$dir/$path')" >&2 mark_fail fi - done < <(perl -ne 'while (/\]\(([^)]+)\)/g) { print "$.:$1\n" }' "$mdfile" 2>/dev/null || true) + done < <(perl -ne 'while (/\]\(\s*]+)>?(?:\s[^)]*)?\)/g) { my $t = $1; $t =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/ge; print "$.:$t\n" }' "$mdfile" 2>/dev/null || true) done < <(git ls-files '*.md') # ------------------------------------------------------------------ verdict diff --git a/src/PostQuantum.FileEncryption.AzureKeyVault/AzureKeyVaultContentKeyProvider.cs b/src/PostQuantum.FileEncryption.AzureKeyVault/AzureKeyVaultContentKeyProvider.cs index 1e2288f..7458813 100644 --- a/src/PostQuantum.FileEncryption.AzureKeyVault/AzureKeyVaultContentKeyProvider.cs +++ b/src/PostQuantum.FileEncryption.AzureKeyVault/AzureKeyVaultContentKeyProvider.cs @@ -115,9 +115,11 @@ public async Task UnwrapKeyAsync(ReadOnlyMemory wrapInfo, Cancella if (!KeyIdMatchesConfiguredKey(recordedKeyId)) { // A clear operational error, like LocalKek's wrong-length message: the caller is - // holding the wrong provider, not (necessarily) a tampered file. + // holding the wrong provider, not (necessarily) a tampered file. The recorded id + // comes from the unauthenticated header, so control characters are neutralized + // before it can reach a log or terminal (mirrors PqContainer.SanitizeForMessage). throw new PqDecryptionException( - $"The container's content key was wrapped under Key Vault key '{recordedKeyId}', " + + $"The container's content key was wrapped under Key Vault key '{SanitizeForMessage(recordedKeyId)}', " + $"but this provider is configured for '{_client.KeyId}'."); } @@ -153,7 +155,35 @@ public async Task UnwrapKeyAsync(ReadOnlyMemory wrapInfo, Cancella /// match the versioned id the service recorded (Key Vault ids are /// https://vault/keys/name[/version]). /// - private bool KeyIdMatchesConfiguredKey(string recordedKeyId) => - string.Equals(recordedKeyId, _client.KeyId, StringComparison.Ordinal) || - recordedKeyId.StartsWith(_client.KeyId + "/", StringComparison.Ordinal); + private bool KeyIdMatchesConfiguredKey(string recordedKeyId) + { + string? configured = _client.KeyId; + if (configured is null) + { + // A locally-operating client built from a JsonWebKey with no key id cannot pin — + // and the naive comparison below would both reject every legitimate id with a + // nonsense message AND accept any hostile recorded id starting with '/' + // (null + "/" == "/", an accidental wildcard). Defer to the cryptographic unwrap: + // a wrong or tampered wrap still fails closed at the unwrap step. + return true; + } + return string.Equals(recordedKeyId, configured, StringComparison.Ordinal) || + recordedKeyId.StartsWith(configured + "/", StringComparison.Ordinal); + } + + /// + /// Replaces control characters in header-derived text before embedding it in an exception + /// message, so a crafted container cannot inject terminal escape sequences or forged lines + /// into whatever log or console the caller writes the message to. + /// + private static string SanitizeForMessage(string value) + { + Span buffer = value.Length <= 256 ? stackalloc char[value.Length] : new char[value.Length]; + for (int i = 0; i < value.Length; i++) + { + char c = value[i]; + buffer[i] = char.IsControl(c) ? '?' : c; + } + return new string(buffer); + } } diff --git a/src/PostQuantum.FileEncryption.Hybrid/Internal/HybridKeyEstablishment.cs b/src/PostQuantum.FileEncryption.Hybrid/Internal/HybridKeyEstablishment.cs index a2ab267..cd6091d 100644 --- a/src/PostQuantum.FileEncryption.Hybrid/Internal/HybridKeyEstablishment.cs +++ b/src/PostQuantum.FileEncryption.Hybrid/Internal/HybridKeyEstablishment.cs @@ -118,7 +118,10 @@ public static byte[] WrapToRecipient(PqHybridPublicKey recipient, byte[] cek) public static byte[] UnwrapFromRecipient(ReadOnlySpan body, PqHybridPrivateKey key) => TryUnwrapBlock(body, key) ?? throw new PqDecryptionException( - "Decryption failed — this file is not encrypted to this key, or it has been altered."); + // The engine's exact generic literal: which STAGE failed (key unwrap here vs. + // body authentication later) must not be observable, or a service exposing raw + // errors becomes a key-possession oracle. + "Decryption failed — the passphrase (or key) is wrong, or the file has been altered, truncated, or corrupted."); // ---- multiple recipients (KeySource = 4) ---- @@ -182,7 +185,8 @@ public static byte[] UnwrapFromRecipients(ReadOnlySpan body, PqHybridPriva } throw new PqDecryptionException( - "Decryption failed — none of the recipients in this file match this key, or it has been altered."); + // Same generic literal as the engine and the single-recipient path — see above. + "Decryption failed — the passphrase (or key) is wrong, or the file has been altered, truncated, or corrupted."); } // ---- shared ---- diff --git a/src/PostQuantum.FileEncryption.Hybrid/PqHybridEncryptor.cs b/src/PostQuantum.FileEncryption.Hybrid/PqHybridEncryptor.cs index 3fcde7f..858d238 100644 --- a/src/PostQuantum.FileEncryption.Hybrid/PqHybridEncryptor.cs +++ b/src/PostQuantum.FileEncryption.Hybrid/PqHybridEncryptor.cs @@ -100,7 +100,9 @@ public async Task EncryptBytesToAsync( { ValidateRecipients(recipients); using var input = new MemoryStream(plaintext.ToArray(), writable: false); - using var output = new MemoryStream(plaintext.Length + 1536); + // Capacity hint clamped: for plaintext within 1536 bytes of Array.MaxLength the naive + // int addition wraps negative and MemoryStream(int) throws a raw ArgumentOutOfRangeException. + using var output = new MemoryStream((int)Math.Min(plaintext.Length + 1536L, Array.MaxLength)); await EncryptToAsync(input, output, recipients, plaintext.Length, null, cancellationToken).ConfigureAwait(false); return output.ToArray(); } diff --git a/src/PostQuantum.FileEncryption.Signing/PqSigner.cs b/src/PostQuantum.FileEncryption.Signing/PqSigner.cs index 29600f1..65b8dc3 100644 --- a/src/PostQuantum.FileEncryption.Signing/PqSigner.cs +++ b/src/PostQuantum.FileEncryption.Signing/PqSigner.cs @@ -31,6 +31,17 @@ public async Task SignFileAsync( ArgumentException.ThrowIfNullOrEmpty(inputPath); ArgumentException.ThrowIfNullOrEmpty(signaturePath); ArgumentNullException.ThrowIfNull(privateKey); + // A detached-signature writer has no meaningful in-place mode: the same path for input + // and signature would atomically replace the signed file with its own signature — + // silent data loss producing a signature that can never verify. Refuse up front. + // (Best-effort: case-insensitive on the OSes whose default filesystems are.) + StringComparison pathComparison = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (string.Equals(Path.GetFullPath(inputPath), Path.GetFullPath(signaturePath), pathComparison)) + { + throw new ArgumentException("The signature path must differ from the input path.", nameof(signaturePath)); + } byte[] signature; await using (var input = FileIo.OpenRead(inputPath)) diff --git a/src/PostQuantum.FileEncryption/Internal/KeyEstablishment.cs b/src/PostQuantum.FileEncryption/Internal/KeyEstablishment.cs index bcbaad6..d839faf 100644 --- a/src/PostQuantum.FileEncryption/Internal/KeyEstablishment.cs +++ b/src/PostQuantum.FileEncryption/Internal/KeyEstablishment.cs @@ -121,6 +121,17 @@ public static async Task DerivePassphraseKeyAsync( throw new PqFormatException( $"Container demands Argon2id parallelism {parallelism}, above this decryptor's configured limit of {limits.MaxArgon2Parallelism} (see PqDecryptionLimits)."); } + if (passphrase.IsEmpty) + { + // Konscious rejects an empty password with a raw ArgumentException. The + // byte-passphrase overloads deliberately accept empty input for legacy + // containers (see PqContainer.EncryptPassphraseAsync), and an empty + // passphrase against a PBKDF2 header runs the KDF and fails with the + // uniform PqDecryptionException — so this path must fail the same way, + // not with a third-party exception steered by an unauthenticated header byte. + throw new PqDecryptionException( + "Decryption failed — the passphrase (or key) is wrong, or the file has been altered, truncated, or corrupted."); + } return await DeriveArgon2idAsync(passphrase, salt, (int)memoryKiB, (int)iterations, parallelism).ConfigureAwait(false); } default: @@ -287,8 +298,9 @@ public static byte[] UnwrapRecipientKey(ContainerHeader header, PqRecipientPriva // PqRecipientPrivateKey.Import validates only the length, so a corrupt or bit-rotted // stored key fails FIPS 203 decode here; mirror the encrypt side and keep it inside // the library's exception contract instead of leaking a platform exception. + _ = ex; // key-dependent failure: no inner exception may distinguish the stage throw new PqDecryptionException( - "Decryption failed: the recipient key is wrong, or the container has been altered.", ex); + "Decryption failed — the passphrase (or key) is wrong, or the file has been altered, truncated, or corrupted."); } using MLKem _decapsulationKey = decapsulationKey; @@ -303,10 +315,11 @@ public static byte[] UnwrapRecipientKey(ContainerHeader header, PqRecipientPriva gcm.Decrypt(wrapNonce, wrappedKey, wrapTag, contentKey, WrapAad); return contentKey; } - catch (AuthenticationTagMismatchException ex) + catch (AuthenticationTagMismatchException) { + // Same generic literal as the engine: no stage-distinguishing message or inner. throw new PqDecryptionException( - "Decryption failed: the recipient key is wrong, or the container has been altered.", ex); + "Decryption failed — the passphrase (or key) is wrong, or the file has been altered, truncated, or corrupted."); } finally { diff --git a/src/PostQuantum.FileEncryption/Internal/PqContainer.cs b/src/PostQuantum.FileEncryption/Internal/PqContainer.cs index 46c277d..123e257 100644 --- a/src/PostQuantum.FileEncryption/Internal/PqContainer.cs +++ b/src/PostQuantum.FileEncryption/Internal/PqContainer.cs @@ -204,7 +204,10 @@ private static byte[] SerializeKeyProviderParams(string providerId, byte[] wrapI { throw new ArgumentException("Provider id must be between 1 and 255 UTF-8 bytes.", nameof(providerId)); } - if (wrapInfo.Length > ushort.MaxValue) + // The real ceiling is what fits the header's uint16 KeyParams field alongside the id + // and length prefix — checking only ushort.MaxValue here would let the last few bytes + // of the range escape as ContainerHeader.Create's internal-parameter ArgumentException. + if (wrapInfo.Length > ContainerFormat.MaxKeyParamsLength - 3 - id.Length) { throw new ArgumentException("Provider wrap info is too large.", nameof(wrapInfo)); } diff --git a/src/PostQuantum.FileEncryption/Internal/PqContainerEngine.cs b/src/PostQuantum.FileEncryption/Internal/PqContainerEngine.cs index b7292f0..fcdbc6c 100644 --- a/src/PostQuantum.FileEncryption/Internal/PqContainerEngine.cs +++ b/src/PostQuantum.FileEncryption/Internal/PqContainerEngine.cs @@ -57,6 +57,11 @@ public static async Task EncryptCoreAsync( IProgress? progress, CancellationToken cancellationToken) { + // Declared outside the try so the finally can zero them: for key-file (PQKF) traffic + // the "plaintext" flowing through these staging buffers IS a private key, so they get + // the same hygiene as the content key. + byte[] current = []; + byte[] next = []; try { using var aes = new AesGcm(contentKey, ContainerFormat.TagLength); @@ -66,8 +71,8 @@ public static async Task EncryptCoreAsync( // Two plaintext buffers let us read one chunk ahead, so we always know whether the // chunk we are about to seal is the final one. ReadAtMostAsync only returns a short // count at end-of-stream, so a partial fill reliably means "no more data". - byte[] current = new byte[header.ChunkSize]; - byte[] next = new byte[header.ChunkSize]; + current = new byte[header.ChunkSize]; + next = new byte[header.ChunkSize]; byte[] ciphertext = new byte[header.ChunkSize]; byte[] tag = new byte[ContainerFormat.TagLength]; byte[] nonce = new byte[ContainerFormat.NonceLength]; @@ -117,6 +122,8 @@ public static async Task EncryptCoreAsync( finally { CryptographicOperations.ZeroMemory(contentKey); + CryptographicOperations.ZeroMemory(current); + CryptographicOperations.ZeroMemory(next); } } @@ -136,6 +143,8 @@ public static async Task DecryptCoreAsync( IProgress? progress, CancellationToken cancellationToken) { + // Declared outside the try so the finally can zero it — see EncryptCoreAsync. + byte[] plaintext = []; try { long? totalPlaintextBytes = DerivePlaintextTotal(totalContainerBytes, header); @@ -154,7 +163,7 @@ public static async Task DecryptCoreAsync( } byte[] ciphertext = new byte[bufferSize]; - byte[] plaintext = new byte[bufferSize]; + plaintext = new byte[bufferSize]; byte[] tag = new byte[ContainerFormat.TagLength]; byte[] nonce = new byte[ContainerFormat.NonceLength]; byte[] frameHeader = new byte[5]; @@ -210,10 +219,13 @@ await ReadExactAsync(source, tag, cancellationToken).ConfigureAwait(false) != ta // throws and writes nothing, so corrupt data never reaches `destination`. aes.Decrypt(nonce, ciphertext.AsSpan(0, (int)length), tag, plaintext.AsSpan(0, (int)length), aad); } - catch (AuthenticationTagMismatchException ex) + catch (AuthenticationTagMismatchException) { + // Deliberately no inner exception: every in-library key-dependent failure + // (wrong passphrase, wrong recipient/KEK, tampered wrap, tampered body) + // must be indistinguishable to the caller — message AND exception shape. throw new PqDecryptionException( - "Decryption failed — the passphrase (or key) is wrong, or the file has been altered, truncated, or corrupted.", ex); + "Decryption failed — the passphrase (or key) is wrong, or the file has been altered, truncated, or corrupted."); } await destination.WriteAsync(plaintext.AsMemory(0, (int)length), cancellationToken).ConfigureAwait(false); @@ -240,6 +252,7 @@ await ReadExactAsync(source, tag, cancellationToken).ConfigureAwait(false) != ta finally { CryptographicOperations.ZeroMemory(contentKey); + CryptographicOperations.ZeroMemory(plaintext); } } diff --git a/src/PostQuantum.FileEncryption/Internal/PqKeyFileFormat.cs b/src/PostQuantum.FileEncryption/Internal/PqKeyFileFormat.cs index dded9de..95e87fa 100644 --- a/src/PostQuantum.FileEncryption/Internal/PqKeyFileFormat.cs +++ b/src/PostQuantum.FileEncryption/Internal/PqKeyFileFormat.cs @@ -121,7 +121,7 @@ public static byte[] Decrypt( input.Length, progress: null, CancellationToken.None) .GetAwaiter().GetResult(); } - catch (NotSupportedException) + catch (NotSupportedException ex) when (ex is not PlatformNotSupportedException) { // The fixed-capacity stream refused a write past 1 + expectedKeyLength + 1: // the (authenticated) plaintext is larger than any key of the expected type, diff --git a/src/PostQuantum.FileEncryption/LocalKekContentKeyProvider.cs b/src/PostQuantum.FileEncryption/LocalKekContentKeyProvider.cs index 5337108..64c232f 100644 --- a/src/PostQuantum.FileEncryption/LocalKekContentKeyProvider.cs +++ b/src/PostQuantum.FileEncryption/LocalKekContentKeyProvider.cs @@ -46,7 +46,17 @@ public LocalKekContentKeyProvider(ReadOnlySpan kek) throw new ArgumentException($"The key-encryption key must be {KekLength} bytes (256 bits).", nameof(kek)); } _kek = kek.ToArray(); - _kekGcm = new AesGcm(_kek, TagLength); + try + { + _kekGcm = new AesGcm(_kek, TagLength); + } + catch + { + // AesGcm can throw (e.g. PlatformNotSupportedException) after the KEK was cloned; + // the half-constructed object can never be disposed, so zero the clone here. + CryptographicOperations.ZeroMemory(_kek); + throw; + } } /// Creates a provider over a fresh random 256-bit KEK (e.g. for tests). @@ -141,11 +151,13 @@ public Task UnwrapKeyAsync(ReadOnlyMemory wrapInfo, CancellationTo } return Task.FromResult(contentKey); } - catch (AuthenticationTagMismatchException ex) + catch (AuthenticationTagMismatchException) { CryptographicOperations.ZeroMemory(contentKey); + // Same generic literal as the engine: a wrong KEK and a tampered body must be + // indistinguishable to the caller — message and exception shape alike. throw new PqDecryptionException( - "Decryption failed: wrong key-encryption key, or the wrapped key has been altered.", ex); + "Decryption failed — the passphrase (or key) is wrong, or the file has been altered, truncated, or corrupted."); } } diff --git a/src/PostQuantum.FileEncryption/PqFileEncryptor.cs b/src/PostQuantum.FileEncryption/PqFileEncryptor.cs index 76f1ed9..d5ba05a 100644 --- a/src/PostQuantum.FileEncryption/PqFileEncryptor.cs +++ b/src/PostQuantum.FileEncryption/PqFileEncryptor.cs @@ -176,7 +176,9 @@ public async Task EncryptBytesAsync( { ArgumentNullException.ThrowIfNull(keyProvider); using var input = new MemoryStream(plaintext.ToArray(), writable: false); - using var output = new MemoryStream(plaintext.Length + 256); + // Capacity hint clamped: for plaintext within 256 bytes of Array.MaxLength the naive + // int addition wraps negative and MemoryStream(int) throws a raw ArgumentOutOfRangeException. + using var output = new MemoryStream((int)Math.Min(plaintext.Length + 256L, Array.MaxLength)); await PqContainer.EncryptKeyProviderAsync( input, output, keyProvider, _options, plaintext.Length, progress, cancellationToken).ConfigureAwait(false); return output.ToArray(); @@ -212,7 +214,9 @@ public async Task EncryptBytesAsync( // Empty passphrases are rejected once at the engine choke point (PqContainer), so // every overload — and any future codec caller — inherits the same gate. using var input = new MemoryStream(plaintext.ToArray(), writable: false); - using var output = new MemoryStream(plaintext.Length + 256); + // Capacity hint clamped: for plaintext within 256 bytes of Array.MaxLength the naive + // int addition wraps negative and MemoryStream(int) throws a raw ArgumentOutOfRangeException. + using var output = new MemoryStream((int)Math.Min(plaintext.Length + 256L, Array.MaxLength)); await PqContainer.EncryptPassphraseAsync( input, output, passphrase, _options, plaintext.Length, progress, cancellationToken).ConfigureAwait(false); return output.ToArray(); diff --git a/test-vectors/SHA256SUMS b/test-vectors/SHA256SUMS index e4990d8..54961b6 100644 --- a/test-vectors/SHA256SUMS +++ b/test-vectors/SHA256SUMS @@ -1,4 +1,6 @@ a16ff8db3dad6a50d9a81cee5a97ce26d875c8dce80a00c93dd7516f080d31de *hybrid-recipient.pqfe +02d3614753172b9eb9690cb35325794fac5e9a67faf5f81377b708725ed00503 *mlkem-recipient.pqfe +ff8599053e453e11aad3149736c7094484a39cf8d982ab9ab285956889ca5444 *mlkem-recipient.key 4e165d1238fcad436bad8b7cd72072b9196e4492aaddfcddfbc82029f0eca4ee *passphrase-argon2id.pqfe ab32cc1d2f5f673d77d80fc2f45307abe4a33a35552f2b5c677a9c5818718547 *passphrase-pbkdf2.pqfe b428f6492c78fe03b8b3197872e60bd737764be066cdaddab594f06f18e6ade6 *passphrase-pbkdf2-rustcore.pqfe diff --git a/test-vectors/mlkem-recipient.key b/test-vectors/mlkem-recipient.key new file mode 100644 index 0000000..591712c Binary files /dev/null and b/test-vectors/mlkem-recipient.key differ diff --git a/test-vectors/mlkem-recipient.pqfe b/test-vectors/mlkem-recipient.pqfe new file mode 100644 index 0000000..bbb323d Binary files /dev/null and b/test-vectors/mlkem-recipient.pqfe differ diff --git a/tests/PostQuantum.FileEncryption.Tests/HybridTests.cs b/tests/PostQuantum.FileEncryption.Tests/HybridTests.cs index 1643b86..1199c4c 100644 --- a/tests/PostQuantum.FileEncryption.Tests/HybridTests.cs +++ b/tests/PostQuantum.FileEncryption.Tests/HybridTests.cs @@ -146,6 +146,30 @@ public async Task Every_unwrap_failure_mode_yields_the_same_message() Assert.Single(messages); } + [Fact] + public async Task Key_unwrap_and_body_authentication_failures_are_indistinguishable() + { + // The cross-STAGE oracle: an attacker submitting a crafted container to a service + // must not learn whether the service's key could unwrap the content key. A wrong key + // (unwrap-stage failure) and a tampered body (post-unwrap authentication failure) + // must yield one message and one exception shape — no distinguishing InnerException. + using var alice = PqHybridKeyPair.Generate(); + using var mallory = PqHybridKeyPair.Generate(); + byte[] container = await new PqHybridEncryptor(Fast()).EncryptBytesAsync(RandomBytes(2000), alice.PublicKey); + + byte[] tamperedBody = (byte[])container.Clone(); + tamperedBody[^1] ^= 0x01; // a final-tag byte: unwrap succeeds, chunk authentication fails + + var wrongKey = await Assert.ThrowsAsync(() => + new PqHybridDecryptor().DecryptBytesAsync(container, mallory.PrivateKey)); + var bodyTamper = await Assert.ThrowsAsync(() => + new PqHybridDecryptor().DecryptBytesAsync(tamperedBody, alice.PrivateKey)); + + Assert.Equal(wrongKey.Message, bodyTamper.Message); + Assert.Null(wrongKey.InnerException); + Assert.Null(bodyTamper.InnerException); + } + [Fact] public async Task Every_header_bit_flip_fails_closed_with_a_typed_exception() { diff --git a/tests/PostQuantum.FileEncryption.Tests/RecipientKnownAnswerVectorTests.cs b/tests/PostQuantum.FileEncryption.Tests/RecipientKnownAnswerVectorTests.cs new file mode 100644 index 0000000..72fc2b3 --- /dev/null +++ b/tests/PostQuantum.FileEncryption.Tests/RecipientKnownAnswerVectorTests.cs @@ -0,0 +1,79 @@ +using System.Text; +using Xunit; + +namespace PostQuantum.FileEncryption.Tests; + +/// +/// Pins the deprecated inline ML-KEM-768 recipient path (KeySource 2) with a committed +/// decrypt-only vector (docs/TEST-VECTORS.md, Vector 9) — the third key-establishment path, +/// previously pinned by nothing: its randomized round-trip tests self-skip on hosts without +/// platform ML-KEM, so a self-consistent serialization or derivation regression would have +/// passed CI everywhere. Encryption is randomized (a fresh KEM encapsulation per container), +/// so the vector is decrypt-only. Generated once on a Linux host with OpenSSL 3.5 (platform +/// ML-KEM) and frozen; the key pair was generated solely for this vector and protects nothing. +/// The artifact bytes themselves are hash-pinned on every host by . +/// +public sealed class RecipientKnownAnswerVectorTests +{ + private const string ExpectedPlaintext = + "PostQuantum.FileEncryption inline ML-KEM-768 recipient known-answer vector."; + + [Fact] + public async Task Pinned_inline_mlkem_recipient_container_decrypts() + { + if (!PqKeyPair.IsSupported) + { + return; // platform-gated; the artifacts stay hash-pinned everywhere regardless + } + + (byte[] container, PqRecipientPrivateKey privateKey) = await LoadVectorAsync(); + using (privateKey) + { + using var output = new MemoryStream(); + await new PqFileDecryptor().DecryptAsync( + new MemoryStream(container, writable: false), output, privateKey); + + Assert.Equal(ExpectedPlaintext, Encoding.UTF8.GetString(output.ToArray())); + } + } + + [Fact] + public async Task Pinned_inline_mlkem_recipient_container_rejects_tampering() + { + if (!PqKeyPair.IsSupported) + { + return; + } + + (byte[] container, PqRecipientPrivateKey privateKey) = await LoadVectorAsync(); + using (privateKey) + { + container[^1] ^= 0x01; // final tag byte + + using var output = new MemoryStream(); + await Assert.ThrowsAsync(() => + new PqFileDecryptor().DecryptAsync( + new MemoryStream(container, writable: false), output, privateKey)); + } + } + + private static async Task<(byte[] container, PqRecipientPrivateKey privateKey)> LoadVectorAsync() + { + string dir = Path.Combine(FindRepositoryRoot(), "test-vectors"); + byte[] container = await File.ReadAllBytesAsync(Path.Combine(dir, "mlkem-recipient.pqfe")); + byte[] keyBytes = await File.ReadAllBytesAsync(Path.Combine(dir, "mlkem-recipient.key")); + return (container, PqRecipientPrivateKey.Import(keyBytes)); + } + + private static string FindRepositoryRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "test-vectors"))) + { + dir = dir.Parent; + } + return dir?.FullName + ?? throw new InvalidOperationException( + $"No 'test-vectors' directory found above {AppContext.BaseDirectory}."); + } +} diff --git a/tests/PostQuantum.FileEncryption.Tests/VectorArtifactTests.cs b/tests/PostQuantum.FileEncryption.Tests/VectorArtifactTests.cs index 2b902b0..4e12792 100644 --- a/tests/PostQuantum.FileEncryption.Tests/VectorArtifactTests.cs +++ b/tests/PostQuantum.FileEncryption.Tests/VectorArtifactTests.cs @@ -18,6 +18,8 @@ public sealed class VectorArtifactTests [InlineData("passphrase-pbkdf2-multichunk.pqfe", "008543AB899A427A33100F1B865B54C5EF0426834B8E7B696EEFFC9B97EA886A")] [InlineData("keyfile.pqkf", "EEDA08E328B028E69F87145642C7898C72BE83E410EBFD595F0B2B50FD9BFB38")] [InlineData("hybrid-recipient.pqfe", "A16FF8DB3DAD6A50D9A81CEE5A97CE26D875C8DCE80A00C93DD7516F080D31DE")] + [InlineData("mlkem-recipient.pqfe", "02D3614753172B9EB9690CB35325794FAC5E9A67FAF5F81377B708725ED00503")] + [InlineData("mlkem-recipient.key", "FF8599053E453E11AAD3149736C7094484A39CF8D982AB9AB285956889CA5444")] public void Committed_vector_artifact_is_byte_identical(string fileName, string expectedSha256) { string path = Path.Combine(FindRepositoryRoot(), "test-vectors", fileName);