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
13 changes: 9 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -177,16 +177,21 @@ jobs:
RUST="samples/pqfe-wasm/target/release/examples/pqfe_io"
export PQFE_PASS="$(openssl rand -hex 24)"

# Sizes straddle the 64 KiB chunk boundary plus empty, tiny, and multi-chunk.
for size in 0 1 65535 65536 65537 5242880; do
# Fixed sizes straddle the 64 KiB chunk boundary (empty, tiny, multi-chunk); the
# random sizes make this a *differential* check that explores new points each run
# rather than re-testing the same six every time.
RANDOM_SIZES="$(for _ in $(seq 5); do echo $(( RANDOM * RANDOM % 5242880 )); done)"
for size in 0 1 65535 65536 65537 5242880 $RANDOM_SIZES; do
head -c "$size" /dev/urandom > "$RUNNER_TEMP/plain.bin"

"$DOTNET" encrypt "$RUNNER_TEMP/plain.bin" "$RUNNER_TEMP/dotnet.pqfe" --passphrase-env PQFE_PASS
# --force: the loop reuses these output paths every iteration, and the CLI now
# refuses to overwrite an existing output without it.
"$DOTNET" encrypt "$RUNNER_TEMP/plain.bin" "$RUNNER_TEMP/dotnet.pqfe" --passphrase-env PQFE_PASS --force
"$RUST" decrypt "$RUNNER_TEMP/dotnet.pqfe" "$RUNNER_TEMP/dotnet.out"
cmp "$RUNNER_TEMP/plain.bin" "$RUNNER_TEMP/dotnet.out"

"$RUST" encrypt "$RUNNER_TEMP/plain.bin" "$RUNNER_TEMP/rust.pqfe"
"$DOTNET" decrypt "$RUNNER_TEMP/rust.pqfe" "$RUNNER_TEMP/rust.out" --passphrase-env PQFE_PASS
"$DOTNET" decrypt "$RUNNER_TEMP/rust.pqfe" "$RUNNER_TEMP/rust.out" --passphrase-env PQFE_PASS --force
cmp "$RUNNER_TEMP/plain.bin" "$RUNNER_TEMP/rust.out"

echo "size $size: both directions OK"
Expand Down
86 changes: 81 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ jobs:
dotnet pack src/PostQuantum.FileEncryption.Signing/PostQuantum.FileEncryption.Signing.csproj --configuration Release -o artifacts
dotnet pack src/PostQuantum.FileEncryption.Aws/PostQuantum.FileEncryption.Aws.csproj --configuration Release -o artifacts
dotnet pack src/PostQuantum.FileEncryption.AzureKeyVault/PostQuantum.FileEncryption.AzureKeyVault.csproj --configuration Release -o artifacts
dotnet pack src/PostQuantum.FileEncryption.Gcp/PostQuantum.FileEncryption.Gcp.csproj --configuration Release -o artifacts
dotnet pack src/PostQuantum.FileEncryption.Extensions.DependencyInjection/PostQuantum.FileEncryption.Extensions.DependencyInjection.csproj --configuration Release -o artifacts
dotnet pack src/PostQuantum.FileEncryption.Analyzers/PostQuantum.FileEncryption.Analyzers.csproj --configuration Release -o artifacts
dotnet pack samples/Pqfe.Cli/Pqfe.Cli.csproj --configuration Release -o artifacts
Expand All @@ -115,6 +116,7 @@ jobs:
dotnet CycloneDX src/PostQuantum.FileEncryption.Signing/PostQuantum.FileEncryption.Signing.csproj -o artifacts --json --filename sbom.signing.cdx.json
dotnet CycloneDX src/PostQuantum.FileEncryption.Aws/PostQuantum.FileEncryption.Aws.csproj -o artifacts --json --filename sbom.aws.cdx.json
dotnet CycloneDX src/PostQuantum.FileEncryption.AzureKeyVault/PostQuantum.FileEncryption.AzureKeyVault.csproj -o artifacts --json --filename sbom.azurekeyvault.cdx.json
dotnet CycloneDX src/PostQuantum.FileEncryption.Gcp/PostQuantum.FileEncryption.Gcp.csproj -o artifacts --json --filename sbom.gcp.cdx.json
dotnet CycloneDX src/PostQuantum.FileEncryption.Extensions.DependencyInjection/PostQuantum.FileEncryption.Extensions.DependencyInjection.csproj -o artifacts --json --filename sbom.di.cdx.json
dotnet CycloneDX samples/Pqfe.Cli/Pqfe.Cli.csproj -o artifacts --json --filename sbom.tool.cdx.json

Expand All @@ -135,14 +137,14 @@ jobs:
# a same-version PackageReference on pack), and a consumer's restore of Hybrid will fail
# if it cannot resolve the core. The shell glob would alphabetically order core before
# Hybrid (".nupkg" sorts before ".Hybrid"), but the order is incidental — make it explicit.
# Excludes anything matching *Hybrid*, *Signing*, *Aws*, *AzureKeyVault*, *Extensions*,
# or *Tool* from this step.
# Excludes anything matching *Hybrid*, *Signing*, *Aws*, *AzureKeyVault*, *Gcp*,
# *Extensions*, or *Tool* from this step.
if: ${{ env.NUGET_API_KEY != '' }}
run: |
set -euo pipefail
shopt -s nullglob
for f in artifacts/PostQuantum.FileEncryption.*.nupkg artifacts/PostQuantum.FileEncryption.*.snupkg; do
case "$f" in *Hybrid*|*Signing*|*Aws*|*AzureKeyVault*|*Extensions*|*Tool*) continue ;; esac
case "$f" in *Hybrid*|*Signing*|*Aws*|*AzureKeyVault*|*Gcp*|*Extensions*|*Tool*) continue ;; esac
echo "Pushing $f"
dotnet nuget push "$f" --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
done
Expand Down Expand Up @@ -182,7 +184,7 @@ jobs:
done

- name: Publish Signing and the cloud key providers to NuGet
# Signing, Aws, and AzureKeyVault each pin only the core (same-version
# Signing, Aws, AzureKeyVault, and Gcp each pin only the core (same-version
# PackageReference on pack), so they need the core indexed — already guaranteed
# above — but have no ordering constraint vs Hybrid or each other.
if: ${{ env.NUGET_API_KEY != '' }}
Expand All @@ -191,7 +193,8 @@ jobs:
shopt -s nullglob
for f in artifacts/PostQuantum.FileEncryption.Signing.*.nupkg artifacts/PostQuantum.FileEncryption.Signing.*.snupkg \
artifacts/PostQuantum.FileEncryption.Aws.*.nupkg artifacts/PostQuantum.FileEncryption.Aws.*.snupkg \
artifacts/PostQuantum.FileEncryption.AzureKeyVault.*.nupkg artifacts/PostQuantum.FileEncryption.AzureKeyVault.*.snupkg; do
artifacts/PostQuantum.FileEncryption.AzureKeyVault.*.nupkg artifacts/PostQuantum.FileEncryption.AzureKeyVault.*.snupkg \
artifacts/PostQuantum.FileEncryption.Gcp.*.nupkg artifacts/PostQuantum.FileEncryption.Gcp.*.snupkg; do
echo "Pushing $f"
dotnet nuget push "$f" --api-key "$NUGET_API_KEY" --source https://api.nuget.org/v3/index.json --skip-duplicate
done
Expand Down Expand Up @@ -250,3 +253,76 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "${GITHUB_REF_NAME}" artifacts/* --title "${GITHUB_REF_NAME}" --notes "See CHANGELOG.md for details."

# Standalone, single-file native `pqfe` binaries so users without the .NET runtime can
# encrypt/sign from the terminal. Each is built natively for its target (AOT cannot
# cross-compile), carries a SLSA-style provenance attestation like the NuGet packages, and
# ships a SHA-256 sum. Runs after the release exists so it can attach the assets to it.
native-cli:
name: Native pqfe CLI (${{ matrix.rid }})
needs: release
permissions:
contents: write # upload release assets
id-token: write # provenance attestation (OIDC)
attestations: write # provenance attestation
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
rid: linux-x64
ext: ''
- os: windows-latest
rid: win-x64
ext: '.exe'
- os: macos-latest
rid: osx-arm64
ext: ''
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
with:
dotnet-version: '10.0.x'

- name: Install AOT prerequisites (Linux)
if: matrix.os == 'ubuntu-latest'
# The .NET native-AOT toolchain on Linux needs clang and zlib headers (same as the
# AOT smoke test in ci.yml).
run: sudo apt-get update && sudo apt-get install -y clang zlib1g-dev

- name: Publish native AOT binary
run: |
dotnet publish samples/Pqfe.Cli/Pqfe.Cli.csproj \
--configuration Release \
--runtime ${{ matrix.rid }} \
-p:PublishAot=true \
-o out

- name: Stage and checksum the binary
shell: bash
run: |
set -euo pipefail
bin="out/pqfe${{ matrix.ext }}"
test -f "$bin" || { echo "AOT publish produced no binary at $bin"; ls -la out; exit 1; }
asset="pqfe-${{ matrix.rid }}${{ matrix.ext }}"
cp "$bin" "$asset"
# sha256sum on Linux/Git-Bash, shasum on macOS.
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$asset" > "$asset.sha256"
else
shasum -a 256 "$asset" > "$asset.sha256"
fi
echo "ASSET=$asset" >> "$GITHUB_ENV"

- name: Attest build provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: ${{ env.ASSET }}

- name: Upload binary to the release
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: gh release upload "${GITHUB_REF_NAME}" "${ASSET}" "${ASSET}.sha256" --clobber
72 changes: 72 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,77 @@ All notable changes to this project are documented here. The format is based on
locked by `Microsoft.CodeAnalysis.PublicApiAnalyzers` baselines and `<EnablePackageValidation>`,
and the `.pqfe` v2 container format is frozen for the entire `1.x` line.

## [1.6.0] - 2026-07-10

The cloud-provider, security-hardening, and developer-experience release. A Google Cloud KMS
envelope-key provider completes the AWS / Azure / GCP trio; an adversarial multi-zone review
of the engine fixed eight fail-closed-contract bugs; and native CLI binaries, differential
round-trip coverage, committed test vectors, and a crypto-agility guide round out the tooling.
No change to the frozen `.pqfe` v2 / `PQKF` v1 / `.sig` v1 formats.

### Added

- **`PostQuantum.FileEncryption.Gcp`** — a Google Cloud KMS envelope-key provider,
completing the AWS KMS / Azure Key Vault / Google Cloud KMS trio over the
`IContentKeyProvider` seam. `GcpKmsContentKeyProvider` generates the per-file content key
locally (Cloud KMS has no server-side data-key generation — the same envelope pattern as
the Azure provider and Google's Tink), wraps it with Cloud KMS `Encrypt` bound to
library-specific additional authenticated data plus optional caller bytes, and unwraps
against only the configured `CryptoKey` — a tampered, foreign-key, or AAD-mismatched blob
fails closed with `PqDecryptionException`, indistinguishable from tampering, while
operational failures propagate as the SDK's own exceptions. The CRC32C integrity fields
Cloud KMS offers are populated on every request and verified on every response (the .NET
SDK does not do this itself), pinned by the RFC 3720 check value in tests. Unit-tested
against an in-process fake reproducing the service's binding and `INVALID_ARGUMENT`
semantics, like its AWS and Azure siblings. No change to the `.pqfe` v2 container format,
which remains **FROZEN** for the `1.x` line.

- **Standalone native `pqfe` CLI binaries** are attached to every release for `linux-x64`,
`win-x64`, and `osx-arm64` — single-file, no .NET runtime required, each with a SHA-256 sum
and a SLSA-style build-provenance attestation. Built natively per target (AOT does not
cross-compile) in the release pipeline.
- **Differential round-trip coverage for the Rust core.** A new property test sweeps the
framing matrix (chunk sizes × data lengths straddling one, two, and three chunks, plus
per-byte tamper and truncation-at-every-length), catching any encode/decode asymmetry the
fixed known-answer vectors cannot. The cross-implementation CI harness now also randomizes
payload sizes each run, so the .NET ↔ Rust agreement is exercised at new points continuously
rather than at six fixed sizes.

### Fixed

- **Encrypted key files (`PQKF`) now validate options and limits at the boundary.**
`ExportEncrypted` validates the KDF options before writing, so an out-of-range option (for
example a salt size or Argon2id parallelism that overflows its single on-disk byte) fails
fast with `ArgumentOutOfRangeException` instead of silently producing a key file that even
the correct passphrase could never open. `ImportEncrypted` likewise validates caller-supplied
`PqDecryptionLimits`, so a below-minimum limit surfaces as the configuration error it is
rather than as a hostile-file-shaped `PqFormatException`.
- **Exception-contract consistency on recipient key establishment.** A corrupt ML-KEM-768
recipient key (the public half on encrypt, the private half on decrypt — both validated only
for length on import) now fails closed inside the library's exception hierarchy
(`PqEncryptionException` / `PqDecryptionException`) instead of leaking a raw platform or
BouncyCastle exception, matching the treatment the mirror paths already had.
- **Detached-signature verification validates the sidecar before hashing the content**, per the
ordering `docs/SIGNATURE-FORMAT.md` mandates, so a structurally invalid signature is rejected
without a full SHA-512 pass over a large input. The two signature components are now evaluated
in independent guarded steps, so an unexpected throw from one half can never skip the other —
both always run and either failing yields the same single generic error.
- **Cancellation is honored immediately before the password KDF.** A token cancelled between
header parsing and key derivation no longer pays the full (potentially gibibyte-scale)
Argon2id/PBKDF2 cost first. The KDF itself remains non-interruptible once started (a library
limitation of the underlying primitives), now noted in `KNOWN-GAPS.md`.
- **`LocalKekContentKeyProvider`** reports a structurally malformed wrapped key as
`PqFormatException`, aligning with every other parser and keeping `PqDecryptionException`
reserved for a single generic authentication-failure message.
- **`pqfe encrypt` / `pqfe decrypt` refuse to overwrite an existing output** unless `--force`
is given (exit code 73), matching `keygen`'s existing "a file silently replaced is a file
lost" guard.
- **Documentation of frozen-format reader leniencies corrected.** `KNOWN-GAPS.md` now records
the KeySource-4 (multi-recipient) body's tolerance of trailing bytes and the abort-on-unknown-
`KemId` scan behavior, the directory-fsync durability boundary, and the `DecryptAtomicAsync`
in-memory ~2 GiB ceiling — all format-v3 candidates or documented limitations, none a `1.x`
behavior change.

## [1.5.0] - 2026-07-02

The key-file, hardening, and developer-experience release. Private keys gain a safe at-rest
Expand Down Expand Up @@ -695,6 +766,7 @@ First release. The **symmetric, passphrase-based engine is production-ready**.
- Bounded work on untrusted headers (KDF cost parameters are range-checked).
- Derived keys, wrapped secrets, and private keys are zeroed after use.

[1.6.0]: https://github.com/systemslibrarian/postquantum-file-encryption/compare/v1.5.0...v1.6.0
[1.5.0]: https://github.com/systemslibrarian/postquantum-file-encryption/compare/v1.4.1...v1.5.0
[1.4.1]: https://github.com/systemslibrarian/postquantum-file-encryption/compare/v1.4.0...v1.4.1
[1.4.0]: https://github.com/systemslibrarian/postquantum-file-encryption/compare/v1.3.0...v1.4.0
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ src/PostQuantum.FileEncryption/ — the library
Internal/PqContainer.cs — orchestration (establish key → header → codec)
Internal/PqKeyFileFormat.cs — the PQKF v1 encrypted key-file framing
Internal/FileIo.cs — atomic temp-file write + in-place ordering helper
src/PostQuantum.FileEncryption.{Hybrid,Signing,Aws,AzureKeyVault,
src/PostQuantum.FileEncryption.{Hybrid,Signing,Aws,AzureKeyVault,Gcp,
Extensions.DependencyInjection,Analyzers}/ — the lockstep sibling packages
tests/PostQuantum.FileEncryption.Tests/ — round-trip, KDF, recipient, known-answer, boundary, fuzz tests
tests/PostQuantum.FileEncryption.Analyzers.Tests/ — analyzer rule tests
Expand Down Expand Up @@ -118,7 +118,7 @@ The on-disk formats are **FROZEN for the entire 1.x line**: the `.pqfe` **v2** c

## When you bump the package version

The eight packages (core, Hybrid, Signing, Aws, AzureKeyVault, DI Extensions, Analyzers,
The nine packages (core, Hybrid, Signing, Aws, AzureKeyVault, Gcp, DI Extensions, Analyzers,
and the `pqfe` Tool) ship in **lockstep**, and the documented version must never lag the
`<Version>` in the `.csproj` files. Bumping the NuGet version is not done until the docs are
swept in the **same change**. After changing `<Version>` in the project files, grep the repo
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
are listed per project in CompatibilitySuppressions.xml.
-->
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>1.3.0</PackageValidationBaselineVersion>
<PackageValidationBaselineVersion>1.5.0</PackageValidationBaselineVersion>

<!-- Reproducibility -->
<Deterministic>true</Deterministic>
Expand Down
Loading
Loading