diff --git a/.github/workflows/packed-consumers.yml b/.github/workflows/packed-consumers.yml new file mode 100644 index 0000000..4fea288 --- /dev/null +++ b/.github/workflows/packed-consumers.yml @@ -0,0 +1,41 @@ +name: Packed consumers + +# Tests the product AS CONSUMERS RECEIVE IT: packs all nine lockstep packages into a local +# feed, builds a clean net8.0 + net10.0 consumer from those packages (no project references), +# asserts the packed analyzers load and fire, runs the full API journey on both frameworks, +# and installs + drives the packed `pqfe` tool end to end. Catches bad package assets, broken +# dependency pins, analyzer packaging mistakes, and tool-payload failures that source builds +# cannot see. See scripts/verify-packed-consumers.sh (also runnable locally). + +on: + push: + branches: [main] + pull_request: + paths: + - 'src/**' + - 'samples/Pqfe.Cli/**' + - 'scripts/verify-packed-consumers.sh' + - '.github/workflows/packed-consumers.yml' + - 'Directory.Build.props' + workflow_dispatch: + +permissions: + contents: read + +jobs: + packed-consumers: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + # 8.0.x provides the runtime for the consumer's net8.0 leg; 10.0.x is the build SDK. + dotnet-version: | + 8.0.x + 10.0.x + + - name: Verify packed consumers + run: bash scripts/verify-packed-consumers.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b9521..da50168 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,24 @@ and the `.pqfe` v2 container format is frozen for the entire `1.x` line. ### Added +- **`PqDecryptionLimits.MaxPlaintextBytes`** — a total-plaintext ceiling enforced *before any + key derivation or decryption work* for every input whose length is known (file, bytes, + atomic, seekable streams): the exact plaintext total is derived from the container length + and over-limit containers are rejected with `PqFormatException`, closing the + size-amplification hole for services that buffer untrusted containers (an external review's + Medium finding). Default is unlimited (no acceptance change); set it to the application's + real maximum when decrypting untrusted input. Enforced identically by the Hybrid decryptor. +- **Packed-consumer verification** (`scripts/verify-packed-consumers.sh` + the + `packed-consumers` workflow): packs all nine packages into a hermetic local feed, builds a + clean net8.0 + net10.0 consumer from the packages alone, asserts the packed analyzers load + and fire (PQFE101), runs the full API journey on both frameworks, and installs + drives the + packed `pqfe` tool end to end (round trips, recipient flow, fingerprints, wrong-passphrase + exit code) — catching package-asset, dependency-pin, analyzer-packaging, and tool-payload + regressions that source builds cannot see. The OSS-Fuzz build script now also builds and + seeds the `decrypt_hybrid` target. + +### Added + - **Public-key fingerprints.** `PqHybridPublicKey.GetFingerprint()` and `PqSigningPublicKey.GetFingerprint()` return a stable, domain-separated fingerprint — `pqfp1:` + URL-safe Base64 SHA-256 over a domain prefix, a purpose tag (recipient and diff --git a/KNOWN-GAPS.md b/KNOWN-GAPS.md index 14a2b91..0257eb4 100644 --- a/KNOWN-GAPS.md +++ b/KNOWN-GAPS.md @@ -245,7 +245,7 @@ Last reviewed against: **`1.7.1`**. See [ROADMAP.md](ROADMAP.md) for the forward overload holds the full decrypted output in a `MemoryStream` until the final frame authenticates, so peak memory is proportional to plaintext size and it cannot exceed the ~2 GiB single-array limit (a larger valid container throws `IOException`, not a `Pq*` - exception, and `PqDecryptionLimits` does not bound this buffer). For untrusted or large + exception; `PqDecryptionLimits.MaxPlaintextBytes` can bound it, and the whole plaintext size, for known-length inputs). For untrusted or large inputs, prefer the file APIs (temp-file staging) or the non-atomic stream overload with a bounded destination. This is documented on the method; noted here for completeness. diff --git a/oss-fuzz/build.sh b/oss-fuzz/build.sh index e7c85df..5db23ba 100755 --- a/oss-fuzz/build.sh +++ b/oss-fuzz/build.sh @@ -6,5 +6,11 @@ cd "$SRC/postquantum-file-encryption/samples/pqfe-wasm" # OSS-Fuzz provides the sanitizer/engine flags; cargo-fuzz honors them. cargo fuzz build -O -FUZZ_TARGET_BIN="fuzz/target/x86_64-unknown-linux-gnu/release/decrypt" -cp "$FUZZ_TARGET_BIN" "$OUT/decrypt" +TARGET_DIR="fuzz/target/x86_64-unknown-linux-gnu/release" +cp "$TARGET_DIR/decrypt" "$OUT/decrypt" +cp "$TARGET_DIR/decrypt_hybrid" "$OUT/decrypt_hybrid" + +# Seed corpora: the committed known-answer vectors give both targets valid containers to +# mutate from the first iteration (the hybrid target's seed matches its compiled-in key). +zip -j "$OUT/decrypt_seed_corpus.zip" fuzz/seed-corpus/*.pqfe +zip -j "$OUT/decrypt_hybrid_seed_corpus.zip" fuzz/seed-corpus/*.pqfe diff --git a/scripts/verify-packed-consumers.sh b/scripts/verify-packed-consumers.sh new file mode 100644 index 0000000..e420938 --- /dev/null +++ b/scripts/verify-packed-consumers.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# Packed-consumer verification: prove the NuGet packages work AS CONSUMERS RECEIVE THEM. +# +# The solution build proves source compatibility; it cannot catch bad package assets, broken +# dependency pins, analyzer packaging mistakes, or tool-payload failures. This script packs +# all nine lockstep packages into a local feed, then: +# +# 1. builds a clean multi-targeted (net8.0 + net10.0) console consumer that references the +# eight library/analyzer packages FROM THE FEED (no project references anywhere), +# 2. asserts the packed analyzers actually load and fire (PQFE101 on a literal passphrase), +# 3. runs a full journey on BOTH target frameworks: passphrase round trip under Untrusted +# limits, hybrid multi-recipient round trip, PQKF private-key export/import, public-key +# fingerprints, detached sign + verify (including a tamper rejection), and DI resolution, +# 4. installs the packed `pqfe` dotnet tool from the feed and drives it end to end: +# passphrase encrypt/decrypt, recipient keygen/encrypt/decrypt, sign/verify, and a +# wrong-passphrase rejection with the documented exit code (65). +# +# Run locally: bash scripts/verify-packed-consumers.sh +# CI: .github/workflows/packed-consumers.yml +set -euo pipefail + +cd "$(git rev-parse --show-toplevel)" +REPO="$PWD" + +VERSION="$(sed -n 's/.*\([^<]*\)<\/Version>.*/\1/p' src/PostQuantum.FileEncryption/PostQuantum.FileEncryption.csproj | head -n1)" +[ -n "$VERSION" ] || { echo "could not read " >&2; exit 2; } +# A distinct prerelease version: the published $VERSION may already sit in the NuGet global +# cache, and NuGet would silently serve that cached copy instead of the freshly packed one — +# masking exactly the packaging regressions this script exists to catch. +LOCALVER="$VERSION-packed" +echo "==> Verifying packed consumers for version $VERSION (packed as $LOCALVER)" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT +FEED="$WORK/feed" +mkdir -p "$FEED" +# Hermetic: nothing from this machine's global package cache can leak into the run. +export NUGET_PACKAGES="$WORK/nuget-packages" + +echo "==> Packing all nine packages into the local feed" +for proj in \ + src/PostQuantum.FileEncryption \ + src/PostQuantum.FileEncryption.Hybrid \ + src/PostQuantum.FileEncryption.Signing \ + src/PostQuantum.FileEncryption.Aws \ + src/PostQuantum.FileEncryption.AzureKeyVault \ + src/PostQuantum.FileEncryption.Gcp \ + src/PostQuantum.FileEncryption.Extensions.DependencyInjection \ + src/PostQuantum.FileEncryption.Analyzers \ + samples/Pqfe.Cli; do + dotnet pack "$proj" -c Release -o "$FEED" --nologo -v q -p:Version="$LOCALVER" +done + +CONSUMER="$WORK/consumer" +mkdir -p "$CONSUMER" +cd "$CONSUMER" + +cat > nuget.config < + + + + + + + +EOF + +cat > Consumer.csproj < + + Exe + net8.0;net10.0 + enable + enable + + + + + + + + + + + + + +EOF + +cat > Program.cs <<'EOF' +using System.Security.Cryptography; +using Microsoft.Extensions.DependencyInjection; +using PostQuantum.FileEncryption; +using PostQuantum.FileEncryption.Hybrid; +using PostQuantum.FileEncryption.Signing; + +// Deliberately a literal: the packed analyzer must load from the .nupkg and flag it (PQFE101), +// which the driving script asserts on the build output. +const string Passphrase = "packed-consumer-journey-passphrase"; + +byte[] secret = RandomNumberGenerator.GetBytes(4096); +var options = new PqEncryptionOptions { Pbkdf2Iterations = 100_000, ChunkSizeBytes = 1024 }; + +// 1. Passphrase round trip under Untrusted limits. +byte[] container = await new PqFileEncryptor(options).EncryptBytesAsync(secret, Passphrase); +byte[] restored = await new PqFileDecryptor(PqDecryptionLimits.Untrusted).DecryptBytesAsync(container, Passphrase); +if (!restored.AsSpan().SequenceEqual(secret)) throw new Exception("passphrase round trip failed"); + +// 2. Hybrid multi-recipient round trip + PQKF export/import + fingerprint. +using var alice = PqHybridKeyPair.Generate(); +using var bob = PqHybridKeyPair.Generate(); +byte[] hybrid = await new PqHybridEncryptor(options).EncryptBytesToAsync(secret, [alice.PublicKey, bob.PublicKey]); +byte[] viaBob = await new PqHybridDecryptor(PqDecryptionLimits.Untrusted).DecryptBytesAsync(hybrid, bob.PrivateKey); +if (!viaBob.AsSpan().SequenceEqual(secret)) throw new Exception("hybrid round trip failed"); + +byte[] keyFile = alice.PrivateKey.ExportEncrypted(Passphrase); +using (var reimported = PqHybridPrivateKey.ImportEncrypted(keyFile, Passphrase, PqDecryptionLimits.Untrusted)) +{ + byte[] viaAlice = await new PqHybridDecryptor().DecryptBytesAsync(hybrid, reimported); + if (!viaAlice.AsSpan().SequenceEqual(secret)) throw new Exception("PQKF import round trip failed"); +} +if (!alice.PublicKey.GetFingerprint().StartsWith("pqfp1:", StringComparison.Ordinal)) + throw new Exception("fingerprint format unexpected"); + +// 3. Detached hybrid signature: verify, and reject a tampered payload. +using var signer = PqSigningKeyPair.Generate(); +byte[] signature = await new PqSigner().SignAsync(new MemoryStream(secret), signer.PrivateKey); +await new PqVerifier().VerifyAsync(new MemoryStream(secret), signature, signer.PublicKey); +bool rejected = false; +try +{ + byte[] tampered = (byte[])secret.Clone(); + tampered[0] ^= 0x01; + await new PqVerifier().VerifyAsync(new MemoryStream(tampered), signature, signer.PublicKey); +} +catch (PqSignatureException) { rejected = true; } +if (!rejected) throw new Exception("tampered content passed verification"); + +// 4. DI package resolves working, limit-carrying services. +var provider = new ServiceCollection() + .AddPqFileEncryption(options, PqDecryptionLimits.Untrusted) + .BuildServiceProvider(); +byte[] viaDi = await provider.GetRequiredService() + .DecryptBytesAsync(await provider.GetRequiredService().EncryptBytesAsync(secret, Passphrase), Passphrase); +if (!viaDi.AsSpan().SequenceEqual(secret)) throw new Exception("DI round trip failed"); + +Console.WriteLine($"CONSUMER-OK {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}"); +EOF + +echo "==> Building the consumer from packed artifacts (both target frameworks)" +BUILD_LOG="$WORK/consumer-build.log" +dotnet build -c Release --nologo > "$BUILD_LOG" 2>&1 || { cat "$BUILD_LOG"; exit 1; } +if ! grep -q "PQFE101" "$BUILD_LOG"; then + echo "FAIL: the packed analyzer did not flag the literal passphrase (PQFE101 missing)" >&2 + cat "$BUILD_LOG" + exit 1 +fi +echo " packed analyzer loaded and fired (PQFE101)" + +for tfm in net8.0 net10.0; do + echo "==> Running the consumer journey on $tfm" + dotnet run -c Release -f "$tfm" --no-build | tail -1 +done + +# The packed tool's apphost resolves the runtime via DOTNET_ROOT when the SDK lives outside +# the default location (setup-dotnet exports it in CI; a user-dir install locally may not). +if [ -z "${DOTNET_ROOT:-}" ]; then + DOTNET_BIN="$(perl -MCwd=realpath -e 'print realpath($ARGV[0])' "$(command -v dotnet)")" + export DOTNET_ROOT="$(dirname "$DOTNET_BIN")" +fi + +echo "==> Installing the packed pqfe tool from the feed" +TOOLS="$WORK/tools" +dotnet tool install --tool-path "$TOOLS" --add-source "$FEED" --configfile nuget.config \ + PostQuantum.FileEncryption.Tool --version "$LOCALVER" > /dev/null +PQFE="$TOOLS/pqfe" + +echo "==> Driving the packed tool end to end" +TDIR="$WORK/tooltest" +mkdir -p "$TDIR" +cd "$TDIR" +export PQFE_PASS='packed-consumer tool passphrase' +echo "tool journey payload" > plain.txt + +"$PQFE" encrypt plain.txt plain.pqfe --passphrase-env PQFE_PASS 2>/dev/null +"$PQFE" decrypt plain.pqfe plain.out --untrusted --passphrase-env PQFE_PASS 2>/dev/null +cmp -s plain.txt plain.out || { echo "FAIL: tool passphrase round trip"; exit 1; } + +set +e +PQFE_PASS='the wrong passphrase' "$PQFE" decrypt plain.pqfe wrong.out --passphrase-env PQFE_PASS 2>/dev/null +rc=$? +set -e +[ "$rc" -eq 65 ] && [ ! -e wrong.out ] || { echo "FAIL: wrong passphrase should exit 65 with no output (got $rc)"; exit 1; } + +"$PQFE" recipient keygen id.key --encrypt --passphrase-env PQFE_PASS 2>/dev/null +"$PQFE" recipient encrypt plain.txt sealed.pqfe --recipient id.key.pub 2>/dev/null +"$PQFE" recipient decrypt sealed.pqfe sealed.out --identity id.key --untrusted --passphrase-env PQFE_PASS 2>/dev/null +cmp -s plain.txt sealed.out || { echo "FAIL: tool recipient round trip"; exit 1; } +FP="$("$PQFE" recipient fingerprint id.key.pub 2>/dev/null)" +case "$FP" in pqfp1:*) ;; *) echo "FAIL: fingerprint output '$FP'"; exit 1 ;; esac + +"$PQFE" keygen sign.key --encrypt --passphrase-env PQFE_PASS 2>/dev/null +"$PQFE" sign plain.txt sign.key --passphrase-env PQFE_PASS 2>/dev/null +"$PQFE" verify plain.txt sign.key.pub 2>/dev/null +set +e +printf 'tampered' >> plain.txt +"$PQFE" verify plain.txt sign.key.pub 2>/dev/null +rc=$? +set -e +[ "$rc" -eq 65 ] || { echo "FAIL: tampered verify should exit 65 (got $rc)"; exit 1; } + +echo "==> PASS — all nine packed packages verified as consumers receive them ($VERSION)" diff --git a/src/PostQuantum.FileEncryption.Hybrid/PqHybridDecryptor.cs b/src/PostQuantum.FileEncryption.Hybrid/PqHybridDecryptor.cs index 8f7f87f..8949d5f 100644 --- a/src/PostQuantum.FileEncryption.Hybrid/PqHybridDecryptor.cs +++ b/src/PostQuantum.FileEncryption.Hybrid/PqHybridDecryptor.cs @@ -47,7 +47,7 @@ public async Task DecryptAsync( // container's length and derives the plaintext total for progress reporting from it. long? total = input.CanSeek ? input.Length - input.Position : null; ContainerHeader header = await PqContainerEngine.ReadHeaderAsync(input, cancellationToken).ConfigureAwait(false); - PqContainer.EnforceChunkLimit(header, _limits); + PqContainer.EnforceChunkLimit(header, _limits, total); byte[] contentKey = header.KeySource switch { ContainerFormat.KeySourceHybridRecipient => HybridKeyEstablishment.UnwrapFromRecipient(header.KeyParams, privateKey), diff --git a/src/PostQuantum.FileEncryption/Internal/PqContainer.cs b/src/PostQuantum.FileEncryption/Internal/PqContainer.cs index 123e257..07fb05f 100644 --- a/src/PostQuantum.FileEncryption/Internal/PqContainer.cs +++ b/src/PostQuantum.FileEncryption/Internal/PqContainer.cs @@ -83,7 +83,7 @@ await InstrumentedAsync("decrypt", "passphrase", totalBytes, async () => { throw new PqDecryptionException("This container is encrypted to a recipient key, not a passphrase."); } - EnforceChunkLimit(header, limits); + EnforceChunkLimit(header, limits, totalBytes); // A hostile header can legally demand the format-maximum KDF cost (up to 2 GiB of // Argon2id memory) which then runs to completion uninterruptibly. Honor a cancelled // token here, before that cost is committed, rather than only after in ReadBodyAsync. @@ -120,7 +120,7 @@ await InstrumentedAsync("decrypt", "ml-kem-recipient", totalBytes, async () => { throw new PqDecryptionException("This container is encrypted with a passphrase, not a recipient key."); } - EnforceChunkLimit(header, limits); + EnforceChunkLimit(header, limits, totalBytes); byte[] contentKey = KeyEstablishment.UnwrapRecipientKey(header, privateKey); await Codec.ReadBodyAsync(source, destination, contentKey, header, totalBytes, progress, cancellationToken).ConfigureAwait(false); }).ConfigureAwait(false); @@ -163,7 +163,7 @@ await InstrumentedAsync("decrypt", "key-provider", totalBytes, async () => { throw new PqDecryptionException("This container was not encrypted with an external key provider."); } - EnforceChunkLimit(header, limits); + EnforceChunkLimit(header, limits, totalBytes); (string providerId, byte[] wrapInfo) = ParseKeyProviderParams(header.KeyParams); if (!string.Equals(providerId, provider.ProviderId, StringComparison.Ordinal)) { @@ -182,18 +182,29 @@ await InstrumentedAsync("decrypt", "key-provider", totalBytes, async () => } /// - /// Rejects a header whose declared chunk size exceeds the decryptor's configured ceiling. - /// Runs before key establishment and before the engine allocates chunk buffers, so a - /// hostile header above the limit costs nothing. Key-independent, so no oracle. - /// Internal (not private) so the Hybrid package's decryptor enforces the same gate. + /// Rejects a header whose declared chunk size — or, when the container's total length is + /// known, whose exact derivable plaintext total — exceeds the decryptor's configured + /// ceilings. Runs before key establishment and before the engine allocates chunk buffers, + /// so a hostile header above a limit costs nothing. Key-independent, so no oracle. + /// Internal (not private) so the Hybrid package's decryptor enforces the same gates. /// - internal static void EnforceChunkLimit(ContainerHeader header, PqDecryptionLimits limits) + internal static void EnforceChunkLimit(ContainerHeader header, PqDecryptionLimits limits, long? totalContainerBytes) { if (header.ChunkSize > limits.MaxChunkSizeBytes) { throw new PqFormatException( $"Container declares a {header.ChunkSize}-byte chunk size, above this decryptor's configured limit of {limits.MaxChunkSizeBytes} bytes (see PqDecryptionLimits)."); } + // DerivePlaintextTotal assumes full non-final frames, which maximizes plaintext for a + // given body length — so it is a safe upper bound even for nonconforming containers + // with short data frames (the frozen reader leniency #5). + if (limits.MaxPlaintextBytes < long.MaxValue + && PqContainerEngine.DerivePlaintextTotal(totalContainerBytes, header) is long plaintextTotal + && plaintextTotal > limits.MaxPlaintextBytes) + { + throw new PqFormatException( + $"Container holds up to {plaintextTotal} bytes of plaintext, above this decryptor's configured limit of {limits.MaxPlaintextBytes} bytes (see PqDecryptionLimits)."); + } } // KeyParams (KeySource=5): ProviderIdLength(1) | ProviderId(UTF-8) | WrapInfoLength(2 BE) | WrapInfo diff --git a/src/PostQuantum.FileEncryption/Internal/PqContainerEngine.cs b/src/PostQuantum.FileEncryption/Internal/PqContainerEngine.cs index fcdbc6c..6ec53e6 100644 --- a/src/PostQuantum.FileEncryption/Internal/PqContainerEngine.cs +++ b/src/PostQuantum.FileEncryption/Internal/PqContainerEngine.cs @@ -262,7 +262,7 @@ await ReadExactAsync(source, tag, cancellationToken).ConfigureAwait(false) != ta /// last carries exactly plaintext bytes, so n — and /// therefore the plaintext size — is fully determined by the body length. /// - private static long? DerivePlaintextTotal(long? totalContainerBytes, ContainerHeader header) + internal static long? DerivePlaintextTotal(long? totalContainerBytes, ContainerHeader header) { if (totalContainerBytes is not long total) { diff --git a/src/PostQuantum.FileEncryption/PqDecryptionLimits.cs b/src/PostQuantum.FileEncryption/PqDecryptionLimits.cs index f74453c..0df4233 100644 --- a/src/PostQuantum.FileEncryption/PqDecryptionLimits.cs +++ b/src/PostQuantum.FileEncryption/PqDecryptionLimits.cs @@ -51,6 +51,20 @@ public sealed class PqDecryptionLimits /// public int MaxChunkSizeBytes { get; init; } = PqEncryptionOptions.MaxChunkSizeBytes; + /// + /// Largest total plaintext size, in bytes, this decryptor will produce from a container + /// whose length is known up front (the file, bytes, atomic, and seekable-stream APIs). + /// The exact plaintext total is derived from the container length and rejected with + /// before any key derivation or decryption work, closing + /// the size-amplification hole where a service that buffered an untrusted container then + /// pays for its full plaintext expansion. Defaults to (no + /// ceiling) — including in , because the acceptable plaintext size + /// is application knowledge: services decrypting untrusted input should set their actual + /// maximum. Containers arriving on unknown-length (non-seekable) streams cannot be + /// pre-checked and are not bounded by this limit. + /// + public long MaxPlaintextBytes { get; init; } = long.MaxValue; + /// The permissive defaults: every limit equals the format maximum, so every legal container decrypts. public static PqDecryptionLimits Default { get; } = new(); @@ -108,6 +122,12 @@ internal void Validate() "Limit must be between 1 and 255."); } + if (MaxPlaintextBytes < 0) + { + throw new ArgumentOutOfRangeException( + nameof(MaxPlaintextBytes), MaxPlaintextBytes, "Limit must not be negative."); + } + if (MaxChunkSizeBytes < PqEncryptionOptions.MinChunkSizeBytes || MaxChunkSizeBytes > PqEncryptionOptions.MaxChunkSizeBytes) { diff --git a/src/PostQuantum.FileEncryption/PublicAPI.Unshipped.txt b/src/PostQuantum.FileEncryption/PublicAPI.Unshipped.txt index 730c068..c330fa8 100644 --- a/src/PostQuantum.FileEncryption/PublicAPI.Unshipped.txt +++ b/src/PostQuantum.FileEncryption/PublicAPI.Unshipped.txt @@ -1,3 +1,5 @@ #nullable enable PostQuantum.FileEncryption.PqDecryptionLimits.MaxArgon2Parallelism.get -> int PostQuantum.FileEncryption.PqDecryptionLimits.MaxArgon2Parallelism.init -> void +PostQuantum.FileEncryption.PqDecryptionLimits.MaxPlaintextBytes.get -> long +PostQuantum.FileEncryption.PqDecryptionLimits.MaxPlaintextBytes.init -> void diff --git a/tests/PostQuantum.FileEncryption.Tests/DecryptionLimitsTests.cs b/tests/PostQuantum.FileEncryption.Tests/DecryptionLimitsTests.cs index 44f018e..f2a47bb 100644 --- a/tests/PostQuantum.FileEncryption.Tests/DecryptionLimitsTests.cs +++ b/tests/PostQuantum.FileEncryption.Tests/DecryptionLimitsTests.cs @@ -247,4 +247,29 @@ public void Parallelism_limit_outside_the_byte_range_is_a_configuration_error(in Assert.Throws(() => new PqFileDecryptor(new PqDecryptionLimits { MaxArgon2Parallelism = limit })); } + + // ---------------------------------------------------------------- total-plaintext ceiling + + [Fact] + public async Task Plaintext_total_above_the_limit_is_rejected_before_derivation() + { + var options = new PqEncryptionOptions { Pbkdf2Iterations = 100_000, ChunkSizeBytes = 1024 }; + byte[] container = await EncryptAsync(RandomBytes(4096), options); + + var strict = new PqFileDecryptor(new PqDecryptionLimits { MaxPlaintextBytes = 4095 }); + await Assert.ThrowsAsync(() => strict.DecryptBytesAsync(container, Passphrase)); + + var exact = new PqFileDecryptor(new PqDecryptionLimits { MaxPlaintextBytes = 4096 }); + Assert.Equal(4096, (await exact.DecryptBytesAsync(container, Passphrase)).Length); + + // Defaults are unlimited — every legal container still opens. + Assert.Equal(4096, (await new PqFileDecryptor().DecryptBytesAsync(container, Passphrase)).Length); + } + + [Fact] + public void Negative_plaintext_limit_is_a_configuration_error() + { + Assert.Throws(() => + new PqFileDecryptor(new PqDecryptionLimits { MaxPlaintextBytes = -1 })); + } }