Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,24 @@ 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
run: |
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
Expand Down
60 changes: 60 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
34 changes: 19 additions & 15 deletions KNOWN-GAPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
6 changes: 6 additions & 0 deletions docs/FUZZING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions docs/TEST-VECTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}.";
Expand Down
Loading
Loading