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: 8 additions & 3 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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-*
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions KNOWN-GAPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 6 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
32 changes: 21 additions & 11 deletions docs/CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

---

Expand Down
9 changes: 7 additions & 2 deletions docs/FUZZING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 13 additions & 3 deletions docs/REPRODUCIBLE-BUILDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions fuzz/PostQuantum.FileEncryption.Fuzz/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
20 changes: 20 additions & 0 deletions samples/pqfe-wasm/tests/conformance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions src/PostQuantum.FileEncryption/Internal/KeyEstablishment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ public static async Task<byte[]> 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:
Expand Down
Loading
Loading