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
41 changes: 41 additions & 0 deletions .github/workflows/packed-consumers.yml
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion KNOWN-GAPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 8 additions & 2 deletions oss-fuzz/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
213 changes: 213 additions & 0 deletions scripts/verify-packed-consumers.sh
Original file line number Diff line number Diff line change
@@ -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>\([^<]*\)<\/Version>.*/\1/p' src/PostQuantum.FileEncryption/PostQuantum.FileEncryption.csproj | head -n1)"
[ -n "$VERSION" ] || { echo "could not read <Version>" >&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
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="local-feed" value="$FEED" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>
EOF

cat > Consumer.csproj <<EOF
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="PostQuantum.FileEncryption" Version="$LOCALVER" />
<PackageReference Include="PostQuantum.FileEncryption.Hybrid" Version="$LOCALVER" />
<PackageReference Include="PostQuantum.FileEncryption.Signing" Version="$LOCALVER" />
<PackageReference Include="PostQuantum.FileEncryption.Aws" Version="$LOCALVER" />
<PackageReference Include="PostQuantum.FileEncryption.AzureKeyVault" Version="$LOCALVER" />
<PackageReference Include="PostQuantum.FileEncryption.Gcp" Version="$LOCALVER" />
<PackageReference Include="PostQuantum.FileEncryption.Extensions.DependencyInjection" Version="$LOCALVER" />
<PackageReference Include="PostQuantum.FileEncryption.Analyzers" Version="$LOCALVER" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
</ItemGroup>
</Project>
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<PqFileDecryptor>()
.DecryptBytesAsync(await provider.GetRequiredService<PqFileEncryptor>().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)"
2 changes: 1 addition & 1 deletion src/PostQuantum.FileEncryption.Hybrid/PqHybridDecryptor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
27 changes: 19 additions & 8 deletions src/PostQuantum.FileEncryption/Internal/PqContainer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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))
{
Expand All @@ -182,18 +182,29 @@ await InstrumentedAsync("decrypt", "key-provider", totalBytes, async () =>
}

/// <summary>
/// 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.
/// </summary>
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
Expand Down
Loading
Loading