From ef8bf3d795c707e12db207583a7c68f92b9b4246 Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Fri, 10 Jul 2026 19:24:35 -0400 Subject: [PATCH 1/7] feat: add Google Cloud KMS envelope-key provider (PostQuantum.FileEncryption.Gcp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes 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), wraps it with Cloud KMS Encrypt bound to library-specific AAD, and unwraps against only the configured CryptoKey — a tampered, foreign-key, or AAD-mismatched blob fails closed with PqDecryptionException while operational failures propagate as the SDK's own exceptions. The CRC32C integrity fields 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. Ships entirely around the frozen .pqfe v2 format via the existing provider seam. Wired into the solution, test project, and release pipeline (pack, SBOM, NuGet push). Unit-tested against an in-process fake reproducing the service's binding and INVALID_ARGUMENT semantics. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 13 +- PostQuantum.FileEncryption.slnx | 1 + src/PostQuantum.FileEncryption.Gcp/Crc32C.cs | 38 +++ .../GcpKmsContentKeyProvider.cs | 229 ++++++++++++++++ .../PostQuantum.FileEncryption.Gcp.csproj | 59 +++++ .../PublicAPI.Shipped.txt | 1 + .../PublicAPI.Unshipped.txt | 6 + src/PostQuantum.FileEncryption.Gcp/README.md | 63 +++++ .../CloudKeyProviderTests.cs | 249 +++++++++++++++++- .../PostQuantum.FileEncryption.Tests.csproj | 1 + 10 files changed, 647 insertions(+), 13 deletions(-) create mode 100644 src/PostQuantum.FileEncryption.Gcp/Crc32C.cs create mode 100644 src/PostQuantum.FileEncryption.Gcp/GcpKmsContentKeyProvider.cs create mode 100644 src/PostQuantum.FileEncryption.Gcp/PostQuantum.FileEncryption.Gcp.csproj create mode 100644 src/PostQuantum.FileEncryption.Gcp/PublicAPI.Shipped.txt create mode 100644 src/PostQuantum.FileEncryption.Gcp/PublicAPI.Unshipped.txt create mode 100644 src/PostQuantum.FileEncryption.Gcp/README.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 608948a..903d8b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 @@ -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 @@ -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 != '' }} @@ -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 diff --git a/PostQuantum.FileEncryption.slnx b/PostQuantum.FileEncryption.slnx index b4dbde1..ceb5814 100644 --- a/PostQuantum.FileEncryption.slnx +++ b/PostQuantum.FileEncryption.slnx @@ -13,6 +13,7 @@ + diff --git a/src/PostQuantum.FileEncryption.Gcp/Crc32C.cs b/src/PostQuantum.FileEncryption.Gcp/Crc32C.cs new file mode 100644 index 0000000..3a46b48 --- /dev/null +++ b/src/PostQuantum.FileEncryption.Gcp/Crc32C.cs @@ -0,0 +1,38 @@ +namespace PostQuantum.FileEncryption.Gcp; + +/// +/// CRC32C (Castagnoli) — the checksum Cloud KMS uses for its request/response integrity +/// fields. This is an error-detecting code, not a cryptographic primitive; authenticity is +/// carried by the AEAD layers on either side of it. Table-driven, reflected polynomial +/// 0x82F63B78, matching RFC 3720 §B.4 and the values the service computes. +/// +internal static class Crc32C +{ + private static readonly uint[] Table = BuildTable(); + + /// Computes the CRC32C of , widened to the service's int64 field type. + public static long Compute(ReadOnlySpan data) + { + uint crc = 0xFFFFFFFFu; + foreach (byte b in data) + { + crc = Table[(crc ^ b) & 0xFF] ^ (crc >> 8); + } + return ~crc; + } + + private static uint[] BuildTable() + { + uint[] table = new uint[256]; + for (uint i = 0; i < 256; i++) + { + uint crc = i; + for (int bit = 0; bit < 8; bit++) + { + crc = (crc & 1) != 0 ? 0x82F63B78u ^ (crc >> 1) : crc >> 1; + } + table[i] = crc; + } + return table; + } +} diff --git a/src/PostQuantum.FileEncryption.Gcp/GcpKmsContentKeyProvider.cs b/src/PostQuantum.FileEncryption.Gcp/GcpKmsContentKeyProvider.cs new file mode 100644 index 0000000..c5c6514 --- /dev/null +++ b/src/PostQuantum.FileEncryption.Gcp/GcpKmsContentKeyProvider.cs @@ -0,0 +1,229 @@ +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; +using Google.Api.Gax.Grpc; +using Google.Cloud.Kms.V1; +using Google.Protobuf; +using Grpc.Core; + +namespace PostQuantum.FileEncryption.Gcp; + +/// +/// An backed by Google Cloud KMS. Each file is +/// encrypted under a fresh per-file content key generated locally and wrapped by Cloud KMS +/// Encrypt under your key-ring key, which never leaves Google Cloud. Decryption sends +/// only the small wrapped blob back to Cloud KMS Decrypt. +/// +/// +/// +/// Unlike AWS KMS, Cloud KMS has no server-side data-key generation, so the 32-byte content +/// key originates in this process () and crosses to the +/// service once, for wrapping — the same envelope pattern Google's Tink library uses. +/// Every wrap is bound to library-specific additional authenticated data (plus any +/// caller-supplied bytes), and unwrap targets only the configured CryptoKey — a blob +/// wrapped under a different key or AAD fails closed with . +/// Operational failures (missing key, permission denied, throttling, network) propagate as +/// so they are not mistaken for tampering. +/// +/// +/// The CRC32C integrity fields Cloud KMS offers are populated on every request and verified +/// on every response (the .NET SDK does not do this for you); a checksum mismatch fails the +/// operation rather than trusting a possibly corrupted round-trip. +/// +/// +/// Instances are immutable and thread-safe; the provider does not own or dispose the supplied +/// KMS client. +/// +/// +public sealed class GcpKmsContentKeyProvider : IContentKeyProvider +{ + // wrapInfo layout: Version(1) ‖ Cloud KMS ciphertext (opaque, self-describing — it names + // the CryptoKeyVersion that produced it, so rotation needs nothing recorded here). + private const byte WrapInfoVersion = 1; + private const int ContentKeyLength = 32; + // Cloud KMS symmetric Encrypt of a 32-byte CEK yields ~113 bytes (a few hundred for + // HSM/EXTERNAL protection levels); 16 KiB is far above any legitimate ciphertext. A + // hostile container can declare up to 65535 bytes of wrapInfo; rejecting oversized blobs + // here keeps the failure inside the library's exception contract (and skips a doomed, + // billable network round-trip) instead of letting a service or transport error surface + // as a raw SDK exception. (Matches the aws-kms provider's local cap.) + private const int MaxKmsCiphertextLength = 16 * 1024; + + // Domain separation, fixed-length so appending caller AAD is unambiguous: a blob wrapped + // by some other Cloud KMS user of the same key cannot be replayed into a .pqfe header, + // and vice versa. + private static readonly byte[] LibraryAadPrefix = + Encoding.UTF8.GetBytes("PostQuantum.FileEncryption/gcp-kms cek-wrap v1\n"); + + // One literal on purpose: every unwrap-authenticity failure must surface with + // byte-identical text, or the difference becomes an oracle. + private const string UnwrapFailedMessage = + "Decryption failed: the wrapped key is invalid, was altered, or was not produced under this key and AAD."; + + private readonly KeyManagementServiceClient _kms; + private readonly string _cryptoKeyName; + private readonly byte[] _aad; + + /// + public string ProviderId => "gcp-kms"; + + /// + /// Creates a provider over a Cloud KMS client and the full resource name of the wrapping + /// key (projects/*/locations/*/keyRings/*/cryptoKeys/*). + /// + /// The Cloud KMS client. The caller owns its lifetime and credentials. + /// + /// The CryptoKey that wraps every content key. Unwrap always targets this key, and + /// Cloud KMS selects the correct CryptoKeyVersion from the ciphertext itself, so + /// old files stay decryptable across key rotation. Must be the full resource name; a + /// malformed name is rejected here () rather than being + /// misreported as a decryption failure later. + /// + /// + /// Optional extra bytes (e.g. a tenant or dataset id) bound into every wrap after the + /// library's own binding; the same bytes are required to unwrap. + /// + public GcpKmsContentKeyProvider( + KeyManagementServiceClient kms, + string cryptoKeyName, + ReadOnlyMemory additionalAuthenticatedData = default) + { + ArgumentNullException.ThrowIfNull(kms); + ArgumentException.ThrowIfNullOrEmpty(cryptoKeyName); + // Cloud KMS reports a malformed resource name as INVALID_ARGUMENT — the same status + // an undecryptable ciphertext gets. Rejecting the typo here keeps a configuration + // mistake from ever being misreported as tampering at unwrap time. + if (!CryptoKeyName.TryParse(cryptoKeyName, out _)) + { + throw new ArgumentException( + "The crypto key name must be a full resource name of the form " + + "projects/*/locations/*/keyRings/*/cryptoKeys/*.", + nameof(cryptoKeyName)); + } + + _kms = kms; + _cryptoKeyName = cryptoKeyName; + _aad = new byte[LibraryAadPrefix.Length + additionalAuthenticatedData.Length]; + LibraryAadPrefix.CopyTo(_aad, 0); + additionalAuthenticatedData.Span.CopyTo(_aad.AsSpan(LibraryAadPrefix.Length)); + } + + /// + public async Task<(byte[] contentKey, byte[] wrapInfo)> WrapNewKeyAsync(CancellationToken cancellationToken = default) + { + byte[] contentKey = RandomNumberGenerator.GetBytes(ContentKeyLength); + try + { + // UnsafeWrap shares the array instead of copying it into an unzeroable ByteString; + // the request does not outlive this call, and the caller owns (and zeroes) the key. + EncryptResponse response = await _kms.EncryptAsync( + new EncryptRequest + { + Name = _cryptoKeyName, + Plaintext = UnsafeByteOperations.UnsafeWrap(contentKey), + AdditionalAuthenticatedData = ByteString.CopyFrom(_aad), + PlaintextCrc32C = Crc32C.Compute(contentKey), + AdditionalAuthenticatedDataCrc32C = Crc32C.Compute(_aad), + }, + CallSettings.FromCancellationToken(cancellationToken)).ConfigureAwait(false); + + // The service echoes whether it saw and verified each request checksum, and + // checksums its own response; anything short of full verification means the + // round-trip cannot be trusted end to end. + if (!response.VerifiedPlaintextCrc32C + || !response.VerifiedAdditionalAuthenticatedDataCrc32C + || response.CiphertextCrc32C != Crc32C.Compute(response.Ciphertext.Span)) + { + throw new PqEncryptionException( + "Cloud KMS Encrypt failed its CRC32C integrity check; the request or response was corrupted in transit."); + } + // The CRC fields cover the payload bytes but not the request's key name; the + // response names the CryptoKeyVersion that actually wrapped. Anything but the + // configured key means the file would be silently unrecoverable under it. + if (!response.Name.StartsWith(_cryptoKeyName + "/cryptoKeyVersions/", StringComparison.Ordinal)) + { + throw new PqEncryptionException( + "Cloud KMS Encrypt responded for an unexpected key; the request may have been corrupted in transit."); + } + + byte[] wrapInfo = new byte[1 + response.Ciphertext.Length]; + wrapInfo[0] = WrapInfoVersion; + response.Ciphertext.Span.CopyTo(wrapInfo.AsSpan(1)); + return (contentKey, wrapInfo); + } + catch + { + CryptographicOperations.ZeroMemory(contentKey); + throw; + } + } + + /// + public async Task UnwrapKeyAsync(ReadOnlyMemory wrapInfo, CancellationToken cancellationToken = default) + { + if (wrapInfo.Length < 2 || wrapInfo.Length > 1 + MaxKmsCiphertextLength || wrapInfo.Span[0] != WrapInfoVersion) + { + throw new PqDecryptionException("The wrapped key is malformed or was not produced by the gcp-kms provider."); + } + + ReadOnlyMemory ciphertext = wrapInfo[1..]; + DecryptResponse response; + try + { + // Decrypt targets the configured CryptoKey, so a valid blob wrapped under a + // *different* key the caller can also decrypt is still rejected — no + // confused-deputy unwraps. + response = await _kms.DecryptAsync( + new DecryptRequest + { + Name = _cryptoKeyName, + Ciphertext = ByteString.CopyFrom(ciphertext.Span), + AdditionalAuthenticatedData = ByteString.CopyFrom(_aad), + CiphertextCrc32C = Crc32C.Compute(ciphertext.Span), + AdditionalAuthenticatedDataCrc32C = Crc32C.Compute(_aad), + }, + CallSettings.FromCancellationToken(cancellationToken)).ConfigureAwait(false); + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.InvalidArgument) + { + // Cloud KMS surfaces a tampered, foreign-key, or AAD-mismatched ciphertext as + // INVALID_ARGUMENT; operational failures use other status codes and propagate. + throw new PqDecryptionException(UnwrapFailedMessage, ex); + } + + byte[] contentKey = ReadAndZero(response.Plaintext); + try + { + if (response.PlaintextCrc32C != Crc32C.Compute(contentKey)) + { + throw new PqDecryptionException( + "Cloud KMS Decrypt failed its CRC32C integrity check; the response was corrupted in transit."); + } + if (contentKey.Length != ContentKeyLength) + { + throw new PqDecryptionException("Cloud KMS returned a content key of unexpected length."); + } + return contentKey; + } + catch + { + CryptographicOperations.ZeroMemory(contentKey); + throw; + } + } + + /// + /// Copies the SDK's plaintext out and zeroes the 's backing array + /// so this type does not leave an extra copy of key material behind (best effort — the + /// gRPC stack may hold others; see KNOWN-GAPS.md). + /// + private static byte[] ReadAndZero(ByteString plaintext) + { + byte[] copy = plaintext.ToByteArray(); + if (MemoryMarshal.TryGetArray(plaintext.Memory, out ArraySegment buffer)) + { + CryptographicOperations.ZeroMemory(buffer); + } + return copy; + } +} diff --git a/src/PostQuantum.FileEncryption.Gcp/PostQuantum.FileEncryption.Gcp.csproj b/src/PostQuantum.FileEncryption.Gcp/PostQuantum.FileEncryption.Gcp.csproj new file mode 100644 index 0000000..ed13f31 --- /dev/null +++ b/src/PostQuantum.FileEncryption.Gcp/PostQuantum.FileEncryption.Gcp.csproj @@ -0,0 +1,59 @@ + + + + net8.0;net10.0 + PostQuantum.FileEncryption.Gcp + PostQuantum.FileEncryption.Gcp + true + + + + + PostQuantum.FileEncryption.Gcp + 1.5.0 + 1.5.0.0 + 1.5.0 + 6.0.0 + PostQuantum.FileEncryption.Gcp + Google Cloud KMS envelope-key provider for PostQuantum.FileEncryption, for .NET 8 and .NET 10. GcpKmsContentKeyProvider implements the IContentKeyProvider seam over Cloud KMS Encrypt/Decrypt: every file is encrypted under a fresh per-file content key that Cloud KMS wraps under your key-ring key — the master key never leaves Google Cloud. The wrap is bound to the configured CryptoKey and library-specific additional authenticated data, every request and response is verified end to end with the CRC32C integrity fields Cloud KMS provides, and unwrap fails closed (PqDecryptionException, no oracle) on any invalid or foreign ciphertext. Works with every PqFileEncryptor/PqFileDecryptor overload that accepts a key provider; rotation re-wraps the small content key instead of re-encrypting the file. Public API surface locked by Microsoft.CodeAnalysis.PublicApiAnalyzers; CycloneDX SBOM and SLSA-style build-provenance attestation on every release. + Initial release of the Google Cloud KMS envelope-key provider, completing the AWS KMS / Azure Key Vault / Google Cloud KMS trio over the IContentKeyProvider seam. No change to the .pqfe v2 container format, which remains FROZEN for the 1.x line. See CHANGELOG.md. + gcp;google-cloud;kms;cloud-kms;gcp-kms;envelope-encryption;key-management;data-key;encryption;file-encryption;aes-gcm;authenticated-encryption;aead;cryptography;security;post-quantum;pqc;dotnet;net8;net10 + MIT + README.md + icon.png + false + https://github.com/systemslibrarian/postquantum-file-encryption + https://github.com/systemslibrarian/postquantum-file-encryption + git + true + true + snupkg + true + + + false + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PostQuantum.FileEncryption.Gcp/PublicAPI.Shipped.txt b/src/PostQuantum.FileEncryption.Gcp/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/PostQuantum.FileEncryption.Gcp/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/PostQuantum.FileEncryption.Gcp/PublicAPI.Unshipped.txt b/src/PostQuantum.FileEncryption.Gcp/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..cdef0f4 --- /dev/null +++ b/src/PostQuantum.FileEncryption.Gcp/PublicAPI.Unshipped.txt @@ -0,0 +1,6 @@ +#nullable enable +PostQuantum.FileEncryption.Gcp.GcpKmsContentKeyProvider +PostQuantum.FileEncryption.Gcp.GcpKmsContentKeyProvider.GcpKmsContentKeyProvider(Google.Cloud.Kms.V1.KeyManagementServiceClient! kms, string! cryptoKeyName, System.ReadOnlyMemory additionalAuthenticatedData = default(System.ReadOnlyMemory)) -> void +PostQuantum.FileEncryption.Gcp.GcpKmsContentKeyProvider.ProviderId.get -> string! +PostQuantum.FileEncryption.Gcp.GcpKmsContentKeyProvider.UnwrapKeyAsync(System.ReadOnlyMemory wrapInfo, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +PostQuantum.FileEncryption.Gcp.GcpKmsContentKeyProvider.WrapNewKeyAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<(byte[]! contentKey, byte[]! wrapInfo)>! diff --git a/src/PostQuantum.FileEncryption.Gcp/README.md b/src/PostQuantum.FileEncryption.Gcp/README.md new file mode 100644 index 0000000..54c849a --- /dev/null +++ b/src/PostQuantum.FileEncryption.Gcp/README.md @@ -0,0 +1,63 @@ +# PostQuantum.FileEncryption.Gcp + +**Envelope encryption with Google Cloud KMS — your master key never leaves Google Cloud.** +`GcpKmsContentKeyProvider` plugs Cloud KMS into +[PostQuantum.FileEncryption](https://www.nuget.org/packages/PostQuantum.FileEncryption)'s +`IContentKeyProvider` seam: every file is encrypted under a fresh per-file content key that +Cloud KMS `Encrypt` wraps under your key-ring key; decryption sends only the small wrapped +blob back to Cloud KMS `Decrypt`. + +```bash +dotnet add package PostQuantum.FileEncryption.Gcp +``` + +## Usage + +```csharp +using Google.Cloud.Kms.V1; +using PostQuantum.FileEncryption; +using PostQuantum.FileEncryption.Gcp; + +var kms = await KeyManagementServiceClient.CreateAsync(); // credentials from Application Default Credentials +var provider = new GcpKmsContentKeyProvider(kms, + "projects/my-project/locations/global/keyRings/my-ring/cryptoKeys/my-app-key"); + +await new PqFileEncryptor().EncryptFileAsync("report.pdf", "report.pdf.pqfe", provider); +await new PqFileDecryptor().DecryptFileAsync("report.pdf.pqfe", "report.pdf", provider); +``` + +Optionally bind extra **additional authenticated data** (required to unwrap): + +```csharp +var provider = new GcpKmsContentKeyProvider(kms, cryptoKeyName, + Encoding.UTF8.GetBytes("tenant=contoso")); +``` + +## Security behavior + +- **The master key stays in Cloud KMS.** Cloud KMS has no server-side data-key generation, + so the per-file content key is generated locally and crosses the boundary once, for + wrapping — the same envelope pattern Google's Tink library uses. Rotation re-wraps the + small content key — multi-gigabyte payloads are never re-encrypted — and unwrap works + across key rotation because the ciphertext itself names the `CryptoKeyVersion`. +- **Bound wraps.** Every wrap carries library-specific additional authenticated data (plus + your bytes), and unwrap targets only the configured `CryptoKey` — a blob wrapped under a + different key or AAD fails closed with `PqDecryptionException`, indistinguishable from + tampering. +- **CRC32C end to end.** The integrity checksums Cloud KMS offers are populated on every + request and verified on every response (the .NET SDK does not do this for you); a + mismatch fails the operation instead of trusting a corrupted round-trip. +- **Operational errors stay operational.** Missing keys, permission denial, throttling, and + network failures surface as the gRPC/SDK's own exceptions, not as decryption failures. +- IAM permission needed: `cloudkms.cryptoKeyVersions.useToEncrypt` to encrypt, + `cloudkms.cryptoKeyVersions.useToDecrypt` to decrypt (both in + `roles/cloudkms.cryptoKeyEncrypterDecrypter`). + +## Versioning + +Kept in **lockstep** with `PostQuantum.FileEncryption`. No change to the `.pqfe` v2 container +format, which remains **FROZEN** for the `1.x` line. + +--- + +*To God be the glory — 1 Corinthians 10:31.* diff --git a/tests/PostQuantum.FileEncryption.Tests/CloudKeyProviderTests.cs b/tests/PostQuantum.FileEncryption.Tests/CloudKeyProviderTests.cs index 8a219df..7d91db4 100644 --- a/tests/PostQuantum.FileEncryption.Tests/CloudKeyProviderTests.cs +++ b/tests/PostQuantum.FileEncryption.Tests/CloudKeyProviderTests.cs @@ -6,19 +6,31 @@ using Azure; using Azure.Security.KeyVault.Keys; using Azure.Security.KeyVault.Keys.Cryptography; +using Google.Api.Gax.Grpc; +using Google.Cloud.Kms.V1; +using Google.Protobuf; +using Grpc.Core; using PostQuantum.FileEncryption.Aws; using PostQuantum.FileEncryption.AzureKeyVault; +using PostQuantum.FileEncryption.Gcp; using Xunit; using static PostQuantum.FileEncryption.Tests.TestSupport; +using AwsDecryptRequest = Amazon.KeyManagementService.Model.DecryptRequest; +using AwsDecryptResponse = Amazon.KeyManagementService.Model.DecryptResponse; +using GcpDecryptRequest = Google.Cloud.Kms.V1.DecryptRequest; +using GcpDecryptResponse = Google.Cloud.Kms.V1.DecryptResponse; +using GcpEncryptRequest = Google.Cloud.Kms.V1.EncryptRequest; +using GcpEncryptResponse = Google.Cloud.Kms.V1.EncryptResponse; namespace PostQuantum.FileEncryption.Tests; /// -/// The AWS KMS and Azure Key Vault envelope-key providers, exercised against in-process fakes -/// of the cloud SDK clients (no credentials, no network). The fakes implement real wrap -/// semantics — AES-GCM with the encryption context as AAD for "KMS", RSA-OAEP-256 for "Key -/// Vault" — so the providers' binding and fail-closed behavior is genuinely tested. Live-cloud -/// integration is deliberately out of CI scope (KNOWN-GAPS.md). +/// The AWS KMS, Azure Key Vault, and Google Cloud KMS envelope-key providers, exercised +/// against in-process fakes of the cloud SDK clients (no credentials, no network). The fakes +/// implement real wrap semantics — AES-GCM with the encryption context (or AAD) as AAD for +/// the two "KMS" services, RSA-OAEP-256 for "Key Vault" — so the providers' binding and +/// fail-closed behavior is genuinely tested. Live-cloud integration is deliberately out of +/// CI scope (KNOWN-GAPS.md). /// public sealed class CloudKeyProviderTests { @@ -177,6 +189,142 @@ public async Task Azure_unversioned_client_accepts_its_own_versioned_wrap() Assert.Equal(contentKey, await provider.UnwrapKeyAsync(wrapInfo)); } + // ---------------------------------------------------------------- Google Cloud KMS + + private const string GcpKeyName = + "projects/unit/locations/global/keyRings/pqfe-test/cryptoKeys/pqfe-test-key"; + + [Fact] + public async Task Gcp_wrap_unwrap_round_trip() + { + var kms = new FakeGcpKmsClient(GcpKeyName); + var provider = new GcpKmsContentKeyProvider(kms, GcpKeyName); + + (byte[] contentKey, byte[] wrapInfo) = await provider.WrapNewKeyAsync(); + byte[] recovered = await provider.UnwrapKeyAsync(wrapInfo); + + Assert.Equal(32, contentKey.Length); + Assert.Equal(contentKey, recovered); + } + + [Fact] + public async Task Gcp_container_round_trip() + { + var kms = new FakeGcpKmsClient(GcpKeyName); + var provider = new GcpKmsContentKeyProvider(kms, GcpKeyName); + byte[] original = RandomBytes(5000); + + byte[] container = await new PqFileEncryptor(Fast()).EncryptBytesAsync(original, provider); + byte[] restored = await new PqFileDecryptor().DecryptBytesAsync(container, provider); + + Assert.Equal(original, restored); + } + + [Fact] + public async Task Gcp_tampered_wrap_fails_closed() + { + var kms = new FakeGcpKmsClient(GcpKeyName); + var provider = new GcpKmsContentKeyProvider(kms, GcpKeyName); + (_, byte[] wrapInfo) = await provider.WrapNewKeyAsync(); + + wrapInfo[^1] ^= 0x01; + await Assert.ThrowsAsync(() => provider.UnwrapKeyAsync(wrapInfo)); + } + + [Fact] + public async Task Gcp_wrong_version_byte_fails_closed() + { + var kms = new FakeGcpKmsClient(GcpKeyName); + var provider = new GcpKmsContentKeyProvider(kms, GcpKeyName); + (_, byte[] wrapInfo) = await provider.WrapNewKeyAsync(); + + wrapInfo[0] = 0xFF; + await Assert.ThrowsAsync(() => provider.UnwrapKeyAsync(wrapInfo)); + await Assert.ThrowsAsync(() => provider.UnwrapKeyAsync(Array.Empty())); + } + + [Fact] + public async Task Gcp_additional_authenticated_data_is_binding() + { + var kms = new FakeGcpKmsClient(GcpKeyName); + var tenantA = new GcpKmsContentKeyProvider(kms, GcpKeyName, "tenant=a"u8.ToArray()); + var tenantB = new GcpKmsContentKeyProvider(kms, GcpKeyName, "tenant=b"u8.ToArray()); + + (_, byte[] wrapInfo) = await tenantA.WrapNewKeyAsync(); + await Assert.ThrowsAsync(() => tenantB.UnwrapKeyAsync(wrapInfo)); + } + + [Fact] + public async Task Gcp_wrap_from_a_different_key_fails_closed() + { + // Two "projects", each with its own master key. The blob from alice's key reaches + // bob's — like the real service, decryption under the wrong key fails with + // INVALID_ARGUMENT, which must surface as the fail-closed exception. + const string bobKeyName = "projects/unit/locations/global/keyRings/pqfe-test/cryptoKeys/bob-key"; + var alice = new GcpKmsContentKeyProvider(new FakeGcpKmsClient(GcpKeyName), GcpKeyName); + var bob = new GcpKmsContentKeyProvider(new FakeGcpKmsClient(bobKeyName), bobKeyName); + + (_, byte[] wrapInfo) = await alice.WrapNewKeyAsync(); + await Assert.ThrowsAsync(() => bob.UnwrapKeyAsync(wrapInfo)); + } + + [Fact] + public async Task Gcp_corrupted_response_checksum_fails_closed() + { + var kms = new FakeGcpKmsClient(GcpKeyName); + var provider = new GcpKmsContentKeyProvider(kms, GcpKeyName); + (_, byte[] wrapInfo) = await provider.WrapNewKeyAsync(); + + kms.CorruptResponseCrc = true; + await Assert.ThrowsAsync(() => provider.WrapNewKeyAsync()); + await Assert.ThrowsAsync(() => provider.UnwrapKeyAsync(wrapInfo)); + } + + [Fact] + public void Gcp_malformed_crypto_key_name_is_rejected_at_construction() + { + // Cloud KMS reports a malformed resource name as INVALID_ARGUMENT — the same status + // an undecryptable ciphertext gets. The constructor must catch the typo so it can + // never be misreported as tampering at unwrap time. + var kms = new FakeGcpKmsClient(GcpKeyName); + Assert.Throws(() => new GcpKmsContentKeyProvider(kms, "my-key")); + Assert.Throws(() => new GcpKmsContentKeyProvider( + kms, "projects/unit/locations/global/keyRings/pqfe-test")); + } + + [Fact] + public async Task Gcp_oversized_wrap_info_fails_closed_locally() + { + // The container header allows up to 65535 bytes of wrapInfo; a legitimate Cloud KMS + // ciphertext is ~113 bytes. Oversized hostile blobs must be rejected before any + // network round-trip, inside the library's exception contract. + var provider = new GcpKmsContentKeyProvider(new FakeGcpKmsClient(GcpKeyName), GcpKeyName); + byte[] oversized = new byte[1 + (16 * 1024) + 1]; + oversized[0] = 0x01; + + await Assert.ThrowsAsync(() => provider.UnwrapKeyAsync(oversized)); + } + + [Fact] + public async Task Gcp_wrap_under_an_unexpected_key_fails_closed() + { + // The CRC fields cover payload bytes but not the request's key name; if the response + // names a different CryptoKeyVersion parent, the file would be silently unrecoverable + // under the configured key — encrypt must fail instead. + var kms = new FakeGcpKmsClient(GcpKeyName) { ReturnWrongKeyName = true }; + var provider = new GcpKmsContentKeyProvider(kms, GcpKeyName); + + await Assert.ThrowsAsync(() => provider.WrapNewKeyAsync()); + } + + [Fact] + public void Gcp_crc32c_matches_the_published_check_value() + { + // The CRC32C check value from RFC 3720 §B.4 — if this drifts, every request the + // provider sends would be rejected by the real service. + Assert.Equal(0xE3069283L, Crc32C.Compute("123456789"u8)); + } + // ---------------------------------------------------------------- fakes /// @@ -213,8 +361,8 @@ public override Task GenerateDataKeyAsync( }); } - public override Task DecryptAsync( - DecryptRequest request, CancellationToken cancellationToken = default) + public override Task DecryptAsync( + AwsDecryptRequest request, CancellationToken cancellationToken = default) { if (request.KeyId is not null && request.KeyId != _keyId) { @@ -239,7 +387,7 @@ public override Task DecryptAsync( throw new InvalidCiphertextException("Ciphertext failed to decrypt."); } - return Task.FromResult(new DecryptResponse + return Task.FromResult(new AwsDecryptResponse { KeyId = _keyId, Plaintext = new MemoryStream(cek), @@ -291,4 +439,89 @@ public override Task UnwrapKeyAsync( } } } + + /// + /// In-process "Cloud KMS": Encrypt/Decrypt with a local master key, AES-GCM, and the + /// request's additional authenticated data as AAD — the same binding semantics as the + /// service, including its CRC32C request/response integrity fields and the + /// INVALID_ARGUMENT contract for undecryptable ciphertext. + /// + private sealed class FakeGcpKmsClient : KeyManagementServiceClient + { + private readonly byte[] _masterKey = RandomNumberGenerator.GetBytes(32); + private readonly string _keyName; + + /// When set, responses carry an off-by-one checksum, as if corrupted in transit. + public bool CorruptResponseCrc { get; set; } + + /// When set, Encrypt responds naming a different key, as if the request name was corrupted. + public bool ReturnWrongKeyName { get; set; } + + public FakeGcpKmsClient(string keyName) => _keyName = keyName; + + public override Task EncryptAsync( + GcpEncryptRequest request, CallSettings? callSettings = null) + { + Assert.Equal(_keyName, request.Name); + + byte[] plaintext = request.Plaintext.ToByteArray(); + byte[] aad = request.AdditionalAuthenticatedData.ToByteArray(); + if (request.PlaintextCrc32C != Crc32C.Compute(plaintext) + || request.AdditionalAuthenticatedDataCrc32C != Crc32C.Compute(aad)) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, "Checksum mismatch.")); + } + + byte[] blob = new byte[12 + 16 + plaintext.Length]; + RandomNumberGenerator.Fill(blob.AsSpan(0, 12)); + using (var gcm = new AesGcm(_masterKey, 16)) + { + gcm.Encrypt(blob.AsSpan(0, 12), plaintext, blob.AsSpan(28), blob.AsSpan(12, 16), aad); + } + + return Task.FromResult(new GcpEncryptResponse + { + Name = (ReturnWrongKeyName ? _keyName + "-other" : _keyName) + "/cryptoKeyVersions/1", + Ciphertext = ByteString.CopyFrom(blob), + CiphertextCrc32C = Crc32C.Compute(blob) + (CorruptResponseCrc ? 1 : 0), + VerifiedPlaintextCrc32C = true, + VerifiedAdditionalAuthenticatedDataCrc32C = true, + }); + } + + public override Task DecryptAsync( + GcpDecryptRequest request, CallSettings? callSettings = null) + { + Assert.Equal(_keyName, request.Name); + + byte[] blob = request.Ciphertext.ToByteArray(); + byte[] aad = request.AdditionalAuthenticatedData.ToByteArray(); + if (request.CiphertextCrc32C != Crc32C.Compute(blob) + || request.AdditionalAuthenticatedDataCrc32C != Crc32C.Compute(aad)) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, "Checksum mismatch.")); + } + if (blob.Length != 12 + 16 + 32) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, "Decryption failed.")); + } + + byte[] cek = new byte[32]; + try + { + using var gcm = new AesGcm(_masterKey, 16); + gcm.Decrypt(blob.AsSpan(0, 12), blob.AsSpan(28), blob.AsSpan(12, 16), cek, aad); + } + catch (AuthenticationTagMismatchException) + { + throw new RpcException(new Status(StatusCode.InvalidArgument, "Decryption failed.")); + } + + return Task.FromResult(new GcpDecryptResponse + { + Plaintext = ByteString.CopyFrom(cek), + PlaintextCrc32C = Crc32C.Compute(cek) + (CorruptResponseCrc ? 1 : 0), + }); + } + } } diff --git a/tests/PostQuantum.FileEncryption.Tests/PostQuantum.FileEncryption.Tests.csproj b/tests/PostQuantum.FileEncryption.Tests/PostQuantum.FileEncryption.Tests.csproj index 485b67b..e1edb06 100644 --- a/tests/PostQuantum.FileEncryption.Tests/PostQuantum.FileEncryption.Tests.csproj +++ b/tests/PostQuantum.FileEncryption.Tests/PostQuantum.FileEncryption.Tests.csproj @@ -31,6 +31,7 @@ + From 2884cc502f749c0552a241af3ca7863de27559af Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Fri, 10 Jul 2026 19:24:55 -0400 Subject: [PATCH 2/7] fix: harden key-file validation, exception contracts, and cancellation (security review) An adversarial multi-zone review of the engine surfaced eight bugs that weakened the fail-closed contract; all are fixed here with regression tests and no change to the frozen .pqfe v2 / PQKF v1 / .sig v1 formats. - PQKF ExportEncrypted validates options before writing, so an out-of-range option can no longer silently produce a key file the correct passphrase could never open; ImportEncrypted validates caller-supplied limits so a below-minimum limit is a configuration error, not a hostile-file rejection. - A corrupt ML-KEM-768 recipient key (public half on encrypt, private half on decrypt) now fails closed as a library exception instead of leaking a raw platform/BouncyCastle exception, matching the mirror paths. - Detached-signature verification validates the sidecar before hashing the content (per SIGNATURE-FORMAT.md ordering), and evaluates both signature components in independent guarded steps so one half throwing cannot skip the other. - Cancellation is honored immediately before the password KDF, so a cancelled token no longer pays the full (up to gibibyte-scale) derivation cost first. - LocalKekContentKeyProvider reports a malformed wrap as PqFormatException, keeping PqDecryptionException reserved for one generic auth-failure message. - pqfe encrypt/decrypt refuse to overwrite an existing output without --force. - DecryptAtomicAsync and the DI limits-overload document their boundaries. Co-Authored-By: Claude Fable 5 --- samples/Pqfe.Cli/Program.cs | 16 +++- ...leEncryptionServiceCollectionExtensions.cs | 7 ++ .../Internal/HybridKeyEstablishment.cs | 25 +++++- .../Internal/HybridSigning.cs | 80 +++++++++++++------ .../PqVerifier.cs | 5 ++ .../Internal/KeyEstablishment.cs | 16 +++- .../Internal/PqContainer.cs | 8 ++ .../Internal/PqKeyFileFormat.cs | 12 ++- .../LocalKekContentKeyProvider.cs | 6 +- .../PqFileDecryptor.cs | 6 +- .../HybridTests.cs | 19 +++++ .../KeyFileTests.cs | 28 +++++++ 12 files changed, 193 insertions(+), 35 deletions(-) diff --git a/samples/Pqfe.Cli/Program.cs b/samples/Pqfe.Cli/Program.cs index f525f7c..ec747aa 100644 --- a/samples/Pqfe.Cli/Program.cs +++ b/samples/Pqfe.Cli/Program.cs @@ -27,6 +27,7 @@ internal static class Program private const int ExitUsage = 64; private const int ExitDataErr = 65; private const int ExitNoInput = 66; + private const int ExitCantCreate = 73; // sysexits.h EX_CANTCREAT — output exists, no --force private const int ExitIoErr = 74; private const int ExitInterrupted = 130; // shell convention for SIGINT @@ -111,7 +112,10 @@ private static async Task Main(string[] args) private static async Task EncryptAsync(string[] rest, CancellationToken cancellationToken) { if (!TryParsePaths(rest, out string? input, out string? output, out var flags)) - return Fail("usage: pqfe encrypt [--argon2id] [--passphrase-env VAR]", ExitUsage); + return Fail("usage: pqfe encrypt [--argon2id] [--passphrase-env VAR] [--force]", ExitUsage); + + if (!flags.Force && File.Exists(output)) + return Fail($"'{output}' already exists; refusing to overwrite (use --force).", ExitCantCreate); var options = new PqEncryptionOptions { @@ -137,7 +141,10 @@ private static async Task EncryptAsync(string[] rest, CancellationToken can private static async Task DecryptAsync(string[] rest, CancellationToken cancellationToken) { if (!TryParsePaths(rest, out string? input, out string? output, out var flags)) - return Fail("usage: pqfe decrypt [--passphrase-env VAR]", ExitUsage); + return Fail("usage: pqfe decrypt [--passphrase-env VAR] [--force]", ExitUsage); + + if (!flags.Force && File.Exists(output)) + return Fail($"'{output}' already exists; refusing to overwrite (use --force).", ExitCantCreate); byte[] passphrase = ReadPassphrase(flags.PassphraseEnv, confirm: false, cancellationToken); try @@ -401,6 +408,9 @@ private static bool TryParsePaths( if (i + 1 >= args.Length) return false; flags = flags with { PassphraseEnv = args[++i] }; break; + case "--force": + flags = flags with { Force = true }; + break; default: if (a.StartsWith('-')) return false; positionals.Add(a); @@ -558,7 +568,7 @@ 130 interrupted (Ctrl+C). """); } - private readonly record struct Flags(bool UseArgon2id, string? PassphraseEnv); + private readonly record struct Flags(bool UseArgon2id, string? PassphraseEnv, bool Force); /// /// A usage-level failure raised deep in a helper. Main maps it to exit 64 after every diff --git a/src/PostQuantum.FileEncryption.Extensions.DependencyInjection/PqFileEncryptionServiceCollectionExtensions.cs b/src/PostQuantum.FileEncryption.Extensions.DependencyInjection/PqFileEncryptionServiceCollectionExtensions.cs index 724d020..c531a80 100644 --- a/src/PostQuantum.FileEncryption.Extensions.DependencyInjection/PqFileEncryptionServiceCollectionExtensions.cs +++ b/src/PostQuantum.FileEncryption.Extensions.DependencyInjection/PqFileEncryptionServiceCollectionExtensions.cs @@ -67,6 +67,13 @@ public static IServiceCollection AddPqFileEncryption( /// /// The same instance, for chaining. /// A limit is outside the format's supported range. + /// + /// Because registration uses TryAdd (see the type-level remarks), an earlier + /// limit-free call + /// wins: this overload's limited is then silently not + /// registered, and the resolved decryptor enforces no ceilings. Register this limits overload + /// first (or as the only registration) when the host decrypts untrusted input. + /// public static IServiceCollection AddPqFileEncryption( this IServiceCollection services, PqEncryptionOptions? options, diff --git a/src/PostQuantum.FileEncryption.Hybrid/Internal/HybridKeyEstablishment.cs b/src/PostQuantum.FileEncryption.Hybrid/Internal/HybridKeyEstablishment.cs index 152903e..a2ab267 100644 --- a/src/PostQuantum.FileEncryption.Hybrid/Internal/HybridKeyEstablishment.cs +++ b/src/PostQuantum.FileEncryption.Hybrid/Internal/HybridKeyEstablishment.cs @@ -47,10 +47,27 @@ public static byte[] WrapToRecipient(PqHybridPublicKey recipient, byte[] cek) var random = new SecureRandom(); var encapsulator = new MLKemEncapsulator(MLKemParameters.ml_kem_768); - encapsulator.Init(MLKemPublicKeyParameters.FromEncoding(MLKemParameters.ml_kem_768, recipient.MlKemEncapsulationKey)); - byte[] kemCiphertext = new byte[encapsulator.EncapsulationLength]; - byte[] sharedSecretPq = new byte[encapsulator.SecretLength]; - encapsulator.Encapsulate(kemCiphertext, sharedSecretPq); + byte[] kemCiphertext; + byte[] sharedSecretPq; + try + { + // Init validates the encoded key (FIPS 203 modulus check) and must run before the + // length properties are read. + encapsulator.Init(MLKemPublicKeyParameters.FromEncoding(MLKemParameters.ml_kem_768, recipient.MlKemEncapsulationKey)); + kemCiphertext = new byte[encapsulator.EncapsulationLength]; + sharedSecretPq = new byte[encapsulator.SecretLength]; + encapsulator.Encapsulate(kemCiphertext, sharedSecretPq); + } + catch (ArgumentException ex) + { + // PqHybridPublicKey.Import validates only the length, so an ML-KEM half with + // out-of-range coefficients fails FIPS 203 validation here. Mirror the X25519 + // small-order handling below and keep it inside the library's exception contract + // instead of leaking a raw BouncyCastle ArgumentException. Encrypt-side, so a + // descriptive message leaks nothing. + throw new PqEncryptionException( + "The recipient public key is invalid: its ML-KEM-768 half failed FIPS 203 validation.", ex); + } try { diff --git a/src/PostQuantum.FileEncryption.Signing/Internal/HybridSigning.cs b/src/PostQuantum.FileEncryption.Signing/Internal/HybridSigning.cs index 9f58c4d..7329b15 100644 --- a/src/PostQuantum.FileEncryption.Signing/Internal/HybridSigning.cs +++ b/src/PostQuantum.FileEncryption.Signing/Internal/HybridSigning.cs @@ -80,11 +80,13 @@ public static byte[] Sign(byte[] contentDigest, PqSigningPrivateKey privateKey) } /// - /// Verifies a serialized sidecar against a SHA-512 content digest. Fail-closed: returns - /// only on full success; structural problems raise , any - /// cryptographic mismatch raises . + /// Validates the sidecar's structural framing (length, magic, version, algorithm) and + /// throws if it is not a recognizable detached signature. + /// Per docs/SIGNATURE-FORMAT.md a verifier MUST clear these checks before hashing + /// the content, so this runs at the public boundary ahead of the (possibly large) SHA-512 + /// pass — a garbage sidecar is rejected without reading the input. /// - public static void Verify(byte[] contentDigest, ReadOnlySpan signature, PqSigningPublicKey publicKey) + public static void ValidateSidecar(ReadOnlySpan signature) { if (signature.Length != SignatureLength) { @@ -102,42 +104,72 @@ public static void Verify(byte[] contentDigest, ReadOnlySpan signature, Pq { throw new PqFormatException("Unsupported detached-signature algorithm."); } + } + + /// + /// Verifies a serialized sidecar against a SHA-512 content digest. Fail-closed: returns + /// only on full success; structural problems raise , any + /// cryptographic mismatch raises . + /// + public static void Verify(byte[] contentDigest, ReadOnlySpan signature, PqSigningPublicKey publicKey) + { + // Idempotent with the boundary call in PqVerifier — kept here so Verify is safe on its + // own (four byte comparisons; negligible). + ValidateSidecar(signature); byte[] message = BuildSignedMessage(contentDigest); byte[] edSig = signature.Slice(HeaderLength, SigningSizes.Ed25519Signature).ToArray(); byte[] mlSig = signature.Slice(HeaderLength + SigningSizes.Ed25519Signature, SigningSizes.MlDsa65Signature).ToArray(); - bool edOk; - bool mlOk; - try + // Each component is evaluated in its own guarded step so that an unexpected throw from + // one half cannot skip the other — the two verifications always both run, and either a + // false result or a caught fault yields the same generic failure below. BouncyCastle + // returns false (not throws) for every hostile-content case we know of; this makes the + // fail-closed, single-message contract independent of that. Process-level faults (OOM, + // cancellation, thread interrupt) are NOT caught — they signal infrastructure, not a + // hostile signature, and must not be reported to a caller as a forgery. + Exception? fault = null; + bool edOk = TryVerifyComponent(() => { var ed = new Ed25519Signer(); ed.Init(forSigning: false, new Ed25519PublicKeyParameters(publicKey.Ed25519PublicKey)); ed.BlockUpdate(message, 0, message.Length); - edOk = ed.VerifySignature(edSig); - + return ed.VerifySignature(edSig); + }, ref fault); + bool mlOk = TryVerifyComponent(() => + { var mlDsa = new MLDsaSigner(MLDsaParameters.ml_dsa_65, deterministic: false); mlDsa.Init(forSigning: false, MLDsaPublicKeyParameters.FromEncoding(MLDsaParameters.ml_dsa_65, publicKey.MlDsaPublicKey)); mlDsa.BlockUpdate(message, 0, message.Length); - mlOk = mlDsa.VerifySignature(mlSig); - } - catch (Exception ex) when (ex is not (OutOfMemoryException or OperationCanceledException or ThreadInterruptedException)) - { - // BouncyCastle returns false (rather than throwing) for every hostile-content case - // we know of, but the fail-closed, single-message contract must not rest on a - // dependency's internals: any unexpected throw becomes the same generic failure, - // never a raw exception that reveals which half rejected the input or why. - // Process-level faults (OOM, cancellation, thread interrupt) pass through — they - // signal infrastructure, not a hostile signature, and a caller that quarantines on - // PqSignatureException must never be told an authentic file is forged. - throw new PqSignatureException(VerifyFailedMessage, ex); - } + return mlDsa.VerifySignature(mlSig); + }, ref fault); // Non-short-circuit: both components are always evaluated, and either failing yields - // the same generic error — no oracle for which half failed. + // the same generic error — no oracle for which half failed. A captured fault (if any) + // rides along only as diagnostics; the message is identical either way. if (!(edOk & mlOk)) { - throw new PqSignatureException(VerifyFailedMessage); + throw fault is null + ? new PqSignatureException(VerifyFailedMessage) + : new PqSignatureException(VerifyFailedMessage, fault); + } + } + + /// + /// Runs one component verification, mapping an unexpected (non-infrastructure) throw to a + /// false result so the caller can still evaluate the other component. The first such + /// fault is captured for diagnostics; infrastructure faults propagate untouched. + /// + private static bool TryVerifyComponent(Func verify, ref Exception? fault) + { + try + { + return verify(); + } + catch (Exception ex) when (ex is not (OutOfMemoryException or OperationCanceledException or ThreadInterruptedException)) + { + fault ??= ex; + return false; } } diff --git a/src/PostQuantum.FileEncryption.Signing/PqVerifier.cs b/src/PostQuantum.FileEncryption.Signing/PqVerifier.cs index 78c7d49..19191b9 100644 --- a/src/PostQuantum.FileEncryption.Signing/PqVerifier.cs +++ b/src/PostQuantum.FileEncryption.Signing/PqVerifier.cs @@ -53,6 +53,9 @@ public async Task VerifyAsync( ArgumentNullException.ThrowIfNull(input); ArgumentNullException.ThrowIfNull(publicKey); + // Reject a structurally invalid sidecar before hashing the (possibly large) input — + // the ordering docs/SIGNATURE-FORMAT.md mandates, so a garbage signature costs nothing. + HybridSigning.ValidateSidecar(signature.Span); byte[] digest = await SHA512.HashDataAsync(input, cancellationToken).ConfigureAwait(false); HybridSigning.Verify(digest, signature.Span, publicKey); } @@ -64,6 +67,8 @@ public void VerifyBytes(ReadOnlySpan data, ReadOnlySpan signature, P { ArgumentNullException.ThrowIfNull(publicKey); + // Structural check before hashing, matching the stream path and the spec's ordering. + HybridSigning.ValidateSidecar(signature); byte[] digest = SHA512.HashData(data); HybridSigning.Verify(digest, signature, publicKey); } diff --git a/src/PostQuantum.FileEncryption/Internal/KeyEstablishment.cs b/src/PostQuantum.FileEncryption/Internal/KeyEstablishment.cs index b7785ad..1d46b73 100644 --- a/src/PostQuantum.FileEncryption/Internal/KeyEstablishment.cs +++ b/src/PostQuantum.FileEncryption/Internal/KeyEstablishment.cs @@ -272,7 +272,21 @@ public static byte[] UnwrapRecipientKey(ContainerHeader header, PqRecipientPriva offset += ContainerFormat.TagLength; byte[] wrappedKey = p.Slice(offset, ContainerFormat.KeyLength).ToArray(); - using MLKem decapsulationKey = MLKem.ImportDecapsulationKey(MLKemAlgorithm.MLKem768, privateKey.DecapsulationKey); + MLKem decapsulationKey; + try + { + decapsulationKey = MLKem.ImportDecapsulationKey(MLKemAlgorithm.MLKem768, privateKey.DecapsulationKey); + } + catch (CryptographicException ex) + { + // PqRecipientPrivateKey.Import validates only the length, so a corrupt or bit-rotted + // stored key fails FIPS 203 decode here; mirror the encrypt side and keep it inside + // the library's exception contract instead of leaking a platform exception. + throw new PqDecryptionException( + "Decryption failed: the recipient key is wrong, or the container has been altered.", ex); + } + + using MLKem _decapsulationKey = decapsulationKey; byte[] sharedSecret = decapsulationKey.Decapsulate(kemCiphertext); try { diff --git a/src/PostQuantum.FileEncryption/Internal/PqContainer.cs b/src/PostQuantum.FileEncryption/Internal/PqContainer.cs index 27f79f2..46c277d 100644 --- a/src/PostQuantum.FileEncryption/Internal/PqContainer.cs +++ b/src/PostQuantum.FileEncryption/Internal/PqContainer.cs @@ -56,6 +56,10 @@ public static async Task EncryptPassphraseAsync( // callers leave them null so salt and nonce prefix are freshly random per file. await InstrumentedAsync("encrypt", "passphrase", totalBytes, async () => { + // The password KDF (Argon2id / PBKDF2) is memory-hard and non-interruptible once + // started, so honor cancellation at the last moment before committing to it — a + // token already cancelled must not pay the full derivation cost. + cancellationToken.ThrowIfCancellationRequested(); (byte[] keyParams, byte[] contentKey) = await KeyEstablishment.BuildPassphraseAsync(passphrase, options, saltOverride).ConfigureAwait(false); // The codec zeroes contentKey in its own finally; this one covers the window where // header creation throws before the codec is ever entered (re-zeroing is harmless). @@ -80,6 +84,10 @@ await InstrumentedAsync("decrypt", "passphrase", totalBytes, async () => throw new PqDecryptionException("This container is encrypted to a recipient key, not a passphrase."); } EnforceChunkLimit(header, limits); + // 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. + cancellationToken.ThrowIfCancellationRequested(); byte[] contentKey = await KeyEstablishment.DerivePassphraseKeyAsync(passphrase, header, limits).ConfigureAwait(false); await Codec.ReadBodyAsync(source, destination, contentKey, header, totalBytes, progress, cancellationToken).ConfigureAwait(false); }).ConfigureAwait(false); diff --git a/src/PostQuantum.FileEncryption/Internal/PqKeyFileFormat.cs b/src/PostQuantum.FileEncryption/Internal/PqKeyFileFormat.cs index 7e1d0f8..454c45d 100644 --- a/src/PostQuantum.FileEncryption/Internal/PqKeyFileFormat.cs +++ b/src/PostQuantum.FileEncryption/Internal/PqKeyFileFormat.cs @@ -46,6 +46,12 @@ public static byte[] Encrypt( byte keyType, ReadOnlySpan keyBytes, ReadOnlySpan passphrase, PqEncryptionOptions? options) { ThrowIfEmptyPassphrase(passphrase); + // Validate before writing anything: an out-of-range option (e.g. a salt or parallelism + // that overflows its on-disk byte) would otherwise serialize a header that disagrees + // with the key actually derived, producing a key file that even the correct passphrase + // can never open. Fail fast at the caller instead. + PqEncryptionOptions effectiveOptions = options ?? DefaultOptions; + effectiveOptions.Validate(); byte[] passphraseBytes = new byte[Encoding.UTF8.GetByteCount(passphrase)]; byte[] plaintext = new byte[1 + keyBytes.Length]; try @@ -59,7 +65,7 @@ public static byte[] Encrypt( output.Write(Magic); output.WriteByte(FormatVersion); PqContainer.EncryptPassphraseAsync( - input, output, passphraseBytes, options ?? DefaultOptions, + input, output, passphraseBytes, effectiveOptions, plaintext.Length, progress: null, CancellationToken.None) .GetAwaiter().GetResult(); return output.ToArray(); @@ -90,6 +96,10 @@ public static byte[] Decrypt( throw new PqFormatException("Unsupported encrypted key file version."); } ThrowIfEmptyPassphrase(passphrase); + // A caller-supplied limit below the format minimum is a configuration error; surface it + // as one here (ArgumentOutOfRangeException) rather than letting it masquerade as a + // hostile-file rejection deep in key establishment. + (limits ?? PqDecryptionLimits.Default).Validate(); byte[] passphraseBytes = new byte[Encoding.UTF8.GetByteCount(passphrase)]; // Sized so the backing buffer never reallocates (the plaintext is always smaller than diff --git a/src/PostQuantum.FileEncryption/LocalKekContentKeyProvider.cs b/src/PostQuantum.FileEncryption/LocalKekContentKeyProvider.cs index 647d695..5337108 100644 --- a/src/PostQuantum.FileEncryption/LocalKekContentKeyProvider.cs +++ b/src/PostQuantum.FileEncryption/LocalKekContentKeyProvider.cs @@ -117,7 +117,11 @@ public Task UnwrapKeyAsync(ReadOnlyMemory wrapInfo, CancellationTo if (wrapInfo.Length != WrapInfoLength) { - throw new PqDecryptionException("The wrapped key is malformed (wrong length)."); + // Structural problem, not an authentication failure: match the taxonomy used by + // every other parser (UnwrapRecipientKey, ParseKeyProviderParams, the hybrid block + // parser) and keep PqDecryptionException reserved for one generic auth-failure + // message, with no second distinguishable message under that type. + throw new PqFormatException("The wrapped key is malformed (wrong length)."); } ReadOnlySpan span = wrapInfo.Span; diff --git a/src/PostQuantum.FileEncryption/PqFileDecryptor.cs b/src/PostQuantum.FileEncryption/PqFileDecryptor.cs index fb18cba..5b7563c 100644 --- a/src/PostQuantum.FileEncryption/PqFileDecryptor.cs +++ b/src/PostQuantum.FileEncryption/PqFileDecryptor.cs @@ -181,7 +181,11 @@ public async Task DecryptBytesAsync( /// /// /// The recovered plaintext is buffered in memory before being written, so peak memory is - /// proportional to the plaintext size. For very large inputs prefer the file API + /// proportional to the plaintext size, and this overload cannot decrypt a plaintext larger + /// than the runtime's single-array ceiling (~2 GiB): such a container throws + /// after the decryption work, and + /// does not bound this buffer. For very large or untrusted + /// inputs prefer the file API /// (), /// which is already atomic via a temp file and rename without buffering. /// diff --git a/tests/PostQuantum.FileEncryption.Tests/HybridTests.cs b/tests/PostQuantum.FileEncryption.Tests/HybridTests.cs index 0278745..1f0ace3 100644 --- a/tests/PostQuantum.FileEncryption.Tests/HybridTests.cs +++ b/tests/PostQuantum.FileEncryption.Tests/HybridTests.cs @@ -91,6 +91,25 @@ await Assert.ThrowsAsync(() => new PqHybridEncryptor(Fast()).EncryptBytesAsync(RandomBytes(100), hostile)); } + [Fact] + public async Task Encrypting_to_a_corrupt_ml_kem_recipient_key_is_rejected_as_a_library_exception() + { + using var keyPair = PqHybridKeyPair.Generate(); + byte[] publicBytes = keyPair.PublicKey.Export(); + // Encoding is X25519(32) ‖ ML-KEM-ek(1184); push the ML-KEM half out of its valid + // FIPS 203 range. Import checks only the length, so encapsulation is the first place + // this fails — and it must surface as PqEncryptionException, not a raw BouncyCastle + // ArgumentException, exactly like the X25519 small-order case above. + for (int i = 32; i < publicBytes.Length; i++) + { + publicBytes[i] = 0xFF; + } + var hostile = PqHybridPublicKey.Import(publicBytes); + + await Assert.ThrowsAsync(() => + new PqHybridEncryptor(Fast()).EncryptBytesAsync(RandomBytes(100), hostile)); + } + [Fact] public async Task Every_unwrap_failure_mode_yields_the_same_message() { diff --git a/tests/PostQuantum.FileEncryption.Tests/KeyFileTests.cs b/tests/PostQuantum.FileEncryption.Tests/KeyFileTests.cs index a218034..72b0d62 100644 --- a/tests/PostQuantum.FileEncryption.Tests/KeyFileTests.cs +++ b/tests/PostQuantum.FileEncryption.Tests/KeyFileTests.cs @@ -147,6 +147,34 @@ public void Caller_supplied_kdf_options_are_honored_not_silently_replaced() Assert.Equal(keyPair.PrivateKey.Export(), restored.Export()); } + [Fact] + public void Out_of_range_export_options_fail_fast_instead_of_writing_an_unopenable_key_file() + { + // A salt size that overflows its single on-disk length byte would otherwise serialize a + // header disagreeing with the key actually derived — a key file the correct passphrase + // can never open. Export must reject it up front, like PqFileEncryptor does. + using var keyPair = PqHybridKeyPair.Generate(); + var badSalt = new PqEncryptionOptions { SaltSizeBytes = 300 }; + var badParallelism = new PqEncryptionOptions { Kdf = PqKdf.Argon2id, Argon2Parallelism = 256 }; + + Assert.Throws(() => keyPair.PrivateKey.ExportEncrypted(Passphrase, badSalt)); + Assert.Throws(() => keyPair.PrivateKey.ExportEncrypted(Passphrase, badParallelism)); + } + + [Fact] + public void A_below_minimum_import_limit_is_a_configuration_error_not_a_format_error() + { + // A limit under the format minimum is the caller's misconfiguration; it must surface as + // ArgumentOutOfRangeException at the call site, not as a hostile-file-shaped + // PqFormatException from deep in key establishment. + using var keyPair = PqHybridKeyPair.Generate(); + byte[] keyFile = keyPair.PrivateKey.ExportEncrypted(Passphrase, FastKdf); + var belowMinimum = new PqDecryptionLimits { MaxArgon2MemoryKiB = 1024 }; + + Assert.Throws(() => + PqHybridPrivateKey.ImportEncrypted(keyFile, Passphrase, belowMinimum)); + } + [Fact] public void Garbage_and_truncated_inputs_are_format_errors() { From d624e6012db650e3ead25522e853a00370f57060 Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Fri, 10 Jul 2026 19:25:11 -0400 Subject: [PATCH 3/7] docs: positioning, trust artifacts, crypto-agility, and ledger corrections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the library's differentiators (open, frozen-format, verifiable) and close the transparency gaps the security review found. - README: a threat-model-in-one-table section, a "verify it yourself" section (provenance, KAT reproduction, cross-implementation), a composable package table, and a zero-friction (no license key) install pitch. - test-vectors/: the known-answer vectors committed as ready-to-use binaries with pinned SHA-256 sums and a 30-second verification walkthrough, guarded by VectorArtifactTests so a frozen artifact cannot drift. - docs/CRYPTO-AGILITY.md: how the format versions, how existing ciphertext stays decryptable across a migration, and the harvest-now-decrypt-later reasoning, stated in one place. - KNOWN-GAPS: correct the frozen-format reader-leniency entry (the KeySource-4 body tolerates trailing bytes / extra blocks and aborts on unknown KemId), and record the directory-fsync durability boundary and DecryptAtomicAsync in-memory ceiling — all format-v3 candidates or documented limitations. - SECURITY.md supported-versions refresh; KEY-MANAGEMENT / COOKBOOK / AUDIT-SCOPE / Hybrid README updated for the Gcp provider and the 55-recipient cap rationale. - CHANGELOG [Unreleased] records the Gcp provider and the security fixes. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 55 ++++++++++++ CLAUDE.md | 4 +- KNOWN-GAPS.md | 66 ++++++++++++--- README.md | 80 ++++++++++++++++-- SECURITY.md | 2 +- docs/AUDIT-SCOPE.md | 7 +- docs/COOKBOOK.md | 3 +- docs/CRYPTO-AGILITY.md | 74 ++++++++++++++++ docs/KEY-MANAGEMENT.md | 13 ++- docs/TEST-VECTORS.md | 3 + .../README.md | 8 ++ test-vectors/README.md | 63 ++++++++++++++ test-vectors/SHA256SUMS | 5 ++ test-vectors/hybrid-recipient.pqfe | Bin 0 -> 1308 bytes test-vectors/keyfile.pqkf | Bin 0 -> 2504 bytes test-vectors/passphrase-argon2id.pqfe | Bin 0 -> 116 bytes test-vectors/passphrase-pbkdf2-rustcore.pqfe | Bin 0 -> 112 bytes test-vectors/passphrase-pbkdf2.pqfe | Bin 0 -> 111 bytes .../VectorArtifactTests.cs | 39 +++++++++ 19 files changed, 393 insertions(+), 29 deletions(-) create mode 100644 docs/CRYPTO-AGILITY.md create mode 100644 test-vectors/README.md create mode 100644 test-vectors/SHA256SUMS create mode 100644 test-vectors/hybrid-recipient.pqfe create mode 100644 test-vectors/keyfile.pqkf create mode 100644 test-vectors/passphrase-argon2id.pqfe create mode 100644 test-vectors/passphrase-pbkdf2-rustcore.pqfe create mode 100644 test-vectors/passphrase-pbkdf2.pqfe create mode 100644 tests/PostQuantum.FileEncryption.Tests/VectorArtifactTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 511264a..07b3369 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,60 @@ All notable changes to this project are documented here. The format is based on locked by `Microsoft.CodeAnalysis.PublicApiAnalyzers` baselines and ``, and the `.pqfe` v2 container format is frozen for the entire `1.x` line. +## [Unreleased] + +### 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. + +### 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 @@ -695,6 +749,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. +[unreleased]: https://github.com/systemslibrarian/postquantum-file-encryption/compare/v1.5.0...HEAD [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 diff --git a/CLAUDE.md b/CLAUDE.md index 1880f8d..5a5183e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 `` in the `.csproj` files. Bumping the NuGet version is not done until the docs are swept in the **same change**. After changing `` in the project files, grep the repo diff --git a/KNOWN-GAPS.md b/KNOWN-GAPS.md index 26147fc..fb9bcdc 100644 --- a/KNOWN-GAPS.md +++ b/KNOWN-GAPS.md @@ -107,13 +107,16 @@ Last reviewed against: **`1.5.0`**. See [ROADMAP.md](ROADMAP.md) for the forward both primitives, runs anywhere). Removal of the inline mode is targeted for a future major release; until then it continues to honour the existing fail-closed contract. - **Cloud KMS/HSM providers are not integration-tested against live clouds in CI.** The - envelope seam (`IContentKeyProvider`, `KeySource = 5`) now has three implementations: the + envelope seam (`IContentKeyProvider`, `KeySource = 5`) now has four implementations: the built-in `LocalKekContentKeyProvider`, **`PostQuantum.FileEncryption.Aws`** (AWS KMS - GenerateDataKey/Decrypt with a bound encryption context), and + GenerateDataKey/Decrypt with a bound encryption context), **`PostQuantum.FileEncryption.AzureKeyVault`** (Key Vault / Managed HSM wrap/unwrap, pinned - key id and algorithm). The cloud providers are unit-tested against in-process fakes of the - SDK clients that reproduce the services' binding semantics — CI has no cloud credentials, - so live-service integration is exercised by consumers, not by this repo's pipeline. + key id and algorithm), and **`PostQuantum.FileEncryption.Gcp`** (Cloud KMS Encrypt/Decrypt + with bound AAD and end-to-end CRC32C verification; the content key is generated locally + because Cloud KMS has no server-side data-key generation). The cloud providers are + unit-tested against in-process fakes of the SDK clients that reproduce the services' + binding semantics — CI has no cloud credentials, so live-service integration is exercised + by consumers, not by this repo's pipeline. HashiCorp Vault and PKCS#11 providers remain unimplemented; rewrap/rotation tooling is still designed-only. See [docs/KEY-MANAGEMENT.md](docs/KEY-MANAGEMENT.md). - **Passphrases are still `string` on the convenience overloads.** The zeroable byte overloads @@ -132,8 +135,11 @@ Last reviewed against: **`1.5.0`**. See [ROADMAP.md](ROADMAP.md) for the forward providers zero every plaintext-key buffer they can reach (including the AWS SDK's response `MemoryStream` buffer), but the key also transits SDK-internal HTTP buffers and, in the AWS case, exists transiently as a base64 `string` inside the JSON reader — a `string` cannot be - zeroed. Those copies live until garbage collection. Same class of limitation as the - BouncyCastle entry above. + zeroed. The GCP provider likewise zeroes every reachable copy (including the response + `ByteString`'s backing array), but the key also crosses protobuf/gRPC serialization + buffers — in *both* directions, since Cloud KMS has no server-side data-key generation and + the locally generated content key is uploaded for wrapping. Those copies live until + garbage collection. Same class of limitation as the BouncyCastle entry above. ### Format and feature gaps @@ -155,15 +161,27 @@ Last reviewed against: **`1.5.0`**. See [ROADMAP.md](ROADMAP.md) for the forward authenticated; only the file's tail past the final frame is outside the envelope. Rejecting trailing data would change frozen v2 behavior (and the Rust/WASM implementation in step), so it is a format-v3 candidate, not a `1.x` change. -- **Two lenient v2 reader corners are frozen with the format.** (1) The header's reserved +- **Three lenient v2 reader corners are frozen with the format.** (1) The header's reserved `Flags` byte is defined "must be 0" but readers do not reject a nonzero value (it is still bound into the AAD, so it cannot be *modified* after encryption); (2) the passphrase - KeyParams parsers tolerate trailing bytes where the recipient parser enforces exact length - (also harmless today — the whole header is AAD, so appended bytes break every frame's - authentication). Neither is exploitable, but both mean a nonconforming writer's container - can decrypt. Tightening either would change frozen v2 reader behavior (and the Rust/WASM - implementation in step), so both are format-v3 candidates, alongside the trailing-data - entry below. + KeyParams parsers tolerate trailing bytes, where the inline recipient parser (KeySource 1/2) + and the single-recipient hybrid block (KeySource 3) enforce exact length; (3) the + multi-recipient hybrid body parser (KeySource 4) consumes exactly its declared block count + and does not check that the body ends there, so trailing bytes or extra blocks past the + count are ignored. All three are harmless today — the whole header is AAD, so appended bytes + break every frame's authentication — but each means a nonconforming writer's container can + decrypt. Tightening any would change frozen v2 reader behavior (and the Rust/WASM + implementation in step), so all are format-v3 candidates, alongside the trailing-data entry + above. +- **A malformed hybrid recipient block aborts the multi-recipient scan.** In a KeySource-4 + container, a Mode-3 block whose `KemId` is not the one known value raises `PqFormatException` + out of the block scan, so a later block that *is* for the caller's key is never tried. This + is fail-closed (no plaintext, no key-dependent oracle — only the format-vs-decryption + exception type differs), but it means one malformed block from a nonconforming writer denies + decryption to every recipient listed after it. Switching abort→skip would make the reader + *accept* containers it currently rejects — a loosening of frozen v2 behavior — so it, too, is + a format-v3 candidate (a future `KemId` such as ML-KEM-1024 is exactly when skip-and-continue + would matter). - **The hybrid KEK combiner does not bind the DH/KEM transcript.** `HKDF(ss_pq ‖ ss_x25519)` omits the KEM ciphertext and the ephemeral/recipient public keys from the derivation, unlike X-Wing or HPKE. Today this is fully covered by the container design — the serialized header, @@ -173,6 +191,11 @@ Last reviewed against: **`1.5.0`**. See [ROADMAP.md](ROADMAP.md) for the forward envelope (rewrap/rotation tooling, detached key blocks) would inherit real block malleability. The combiner is spec-frozen with v2 (`FILE-FORMAT.md`), so transcript binding is a format-v3 item, recorded here so no rewrap feature ships without it. +- **The hybrid multi-recipient cap is 55.** Each KeySource-4 recipient entry is 1,186 bytes + and the v2 header's KeyParams length field is a `ushort`, so ⌊65,534 / 1,186⌋ = 55 + recipients fit. The limit is enforced pre-flight (since `1.5.0`) with a clear message + rather than failing after the wrapping work. Widening the field is a format-v3 candidate; + for larger audiences today, wrap to a KMS-held group key via `IContentKeyProvider`. - **No compression, no deduplication.** Out of scope. (Encrypted private-key *files* left this list with the `PQKF` format in `1.5.0` — see "Resolved in `1.5.0`" above — but key management beyond that framing remains out of scope.) @@ -197,6 +220,21 @@ Last reviewed against: **`1.5.0`**. See [ROADMAP.md](ROADMAP.md) for the forward **Destination integrity is preserved either way** — no partial or corrupted file is ever moved to the destination path; only the temp file may linger. Operators who need guaranteed cleanup of orphaned `*.tmp-*` files should run a periodic sweep. +- **Rename durability on power loss is not guaranteed.** The file write path fsyncs the temp + file's data before `File.Move`, so a crash cannot leave a *truncated* file at the + destination. It does **not** fsync the containing directory after the rename (there is no + portable BCL API for it; Linux would need a P/Invoked directory `fsync`), so a power loss in + the seconds after a successful return can, on some filesystems, roll the directory entry + back to the pre-existing file. The fail-safe still holds — the destination is either the old + file or the new one, never a partial — but callers requiring the rename itself to survive + immediate power loss should fsync the directory (or the whole volume) themselves. +- **`DecryptAtomicAsync` buffers the whole plaintext in memory.** The all-or-nothing stream + 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 + 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. ### Demos diff --git a/README.md b/README.md index 90b8cc7..38b7600 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,11 @@ constant-memory streaming for files of any size, a frozen and publicly specified container format, and a production post-quantum upgrade path.** +MIT · **no license keys, no activation, no sign-up** · fail-closed with **no decryption +oracle** · frozen, publicly specified format with **cross-implementation test vectors** · +**X25519 + ML-KEM-768** (FIPS 203) hybrid recipients · **SBOM + build-provenance +attestation** on every release — and [you can verify all of it yourself](#verify-it-yourself). + Two friendly classes — `PqFileEncryptor` and `PqFileDecryptor` — handle authenticated, chunked, streaming encryption with strong, modern defaults. A 10 GB backup encrypts in roughly 130 KB of working memory, stream-to-stream or file-to-file. You should not have to @@ -53,6 +58,21 @@ you never have to take that on faith. - **Honest about limits.** The [Known Gaps](https://github.com/systemslibrarian/postquantum-file-encryption/blob/main/KNOWN-GAPS.md) ledger lists everything that is not yet done. The library has not been independently audited; engagements are welcome. +## The threat model, in one table + +| Defends against | How | +| --- | --- | +| Tampering, chunk reordering, splicing, truncation | Per-chunk AES-256-GCM; header, chunk position, and final-chunk marker bound as AAD — any change is an authentication failure | +| Decryption oracles | Every authenticity failure throws the same generic `PqDecryptionException` — wrong passphrase, flipped bit, and forged header are indistinguishable | +| Harvest-now-decrypt-later | AES-256 content encryption today; X25519 + ML-KEM-768 (FIPS 203) key establishment for recipients via the Hybrid package | +| Passphrase guessing | Argon2id or PBKDF2 with a unique per-file salt | +| Hostile containers | KDF cost and chunk-size limits enforced *before* any expensive work | + +It does **not** hide metadata (names, timestamps, size to within a chunk), rescue a weak +passphrase, or protect a compromised endpoint. The full model — assets, actors, trust +boundaries, assumptions, and explicit non-goals — is published in +[docs/THREAT-MODEL.md](https://github.com/systemslibrarian/postquantum-file-encryption/blob/main/docs/THREAT-MODEL.md). + ## When to use this - You're on **.NET 8 or .NET 10** and want a drop-in, fail-closed file/stream encryptor @@ -91,6 +111,9 @@ Being clear about scope is part of the security contract. Reach for something el ## Install +No sign-up, no license key, no activation call — add the package and encrypt in the same +minute: + ```bash # Core (passphrase + envelope-key engine) dotnet add package PostQuantum.FileEncryption --version 1.5.0 @@ -104,6 +127,7 @@ dotnet add package PostQuantum.FileEncryption.Signing --version 1.5.0 # Optional: cloud envelope-key providers (the master key stays in your KMS/HSM) dotnet add package PostQuantum.FileEncryption.Aws # AWS KMS dotnet add package PostQuantum.FileEncryption.AzureKeyVault # Azure Key Vault / Managed HSM +dotnet add package PostQuantum.FileEncryption.Gcp # Google Cloud KMS # Optional: Microsoft.Extensions.DependencyInjection integration # (AddPqFileEncryption() / AddPqHybridFileEncryption()) @@ -120,6 +144,19 @@ both. Core depends only on else is from .NET's `System.Security.Cryptography`. The Hybrid package additionally pulls in `BouncyCastle.Cryptography` so it runs on every platform without a native ML-KEM dependency. +The family is deliberately **composable, not monolithic** — the core stays small and +auditable, and you add exactly the surface you need: + +| Package (`PostQuantum.FileEncryption` +) | Adds | Reach for it when | +| --- | --- | --- | +| *(the core itself)* | The fail-closed engine: passphrase + envelope-key encryption | Always — everything else builds on it | +| `.Hybrid` | X25519 + ML-KEM-768 public-key encryption, multi-recipient | Recipients decrypt with a private key, not a passphrase | +| `.Signing` | Detached Ed25519 + ML-DSA-65 signatures | You need sender authenticity, not just secrecy | +| `.Aws` / `.AzureKeyVault` / `.Gcp` | KMS envelope-key providers | The master key must stay in your cloud KMS/HSM | +| `.Extensions.DependencyInjection` | `AddPqFileEncryption()` etc. | ASP.NET Core / generic-host apps | +| `.Analyzers` | Compile-time misuse checks (dev-only) | Always safe to add — it ships no runtime code | +| `.Tool` | The `pqfe` CLI (`dotnet tool install`) | Encrypt/sign from scripts and terminals | + --- ## ▶ Try it @@ -192,6 +229,33 @@ sidesteps this with the Rust/WASM core.) --- +## Verify it yourself + +None of this library's trust claims ask for faith — each one is checkable in a command: + +```bash +# 1. Provenance: the package you downloaded was built by this repo's public release +# workflow, on GitHub's runners, from a tagged commit you can read: +gh attestation verify PostQuantum.FileEncryption.1.5.0.nupkg \ + --repo systemslibrarian/postquantum-file-encryption + +# 2. The frozen format: reproduce the pinned known-answer vectors — byte-exact +# ciphertexts published in docs/TEST-VECTORS.md and committed as ready-to-use +# binaries in test-vectors/ (decrypt one with the pqfe CLI and the published +# passphrase — see test-vectors/README.md); if the format drifted, this fails: +dotnet test tests/PostQuantum.FileEncryption.Tests -c Release \ + --filter FullyQualifiedName~KnownAnswerVector + +# 3. Independence: a separate Rust implementation decrypts the same vectors +# (and the .NET tests decrypt a Rust-produced container): +cd samples/pqfe-wasm && cargo test +``` + +SBOM verification and the full supply-chain walkthrough are in +[docs/SUPPLY-CHAIN.md](https://github.com/systemslibrarian/postquantum-file-encryption/blob/main/docs/SUPPLY-CHAIN.md). + +--- + ## Public API at a glance The surface is small on purpose — these are the types you actually touch: @@ -391,17 +455,19 @@ Encrypt under an external key provider so the master key never enters your proce built-in, dependency-free local-KEK provider is included, and production cloud providers ship as companion packages: [**PostQuantum.FileEncryption.Aws**](https://www.nuget.org/packages/PostQuantum.FileEncryption.Aws) -(AWS KMS) and +(AWS KMS), [**PostQuantum.FileEncryption.AzureKeyVault**](https://www.nuget.org/packages/PostQuantum.FileEncryption.AzureKeyVault) -(Azure Key Vault / Managed HSM). +(Azure Key Vault / Managed HSM), and +[**PostQuantum.FileEncryption.Gcp**](https://www.nuget.org/packages/PostQuantum.FileEncryption.Gcp) +(Google Cloud KMS). ```csharp using var provider = LocalKekContentKeyProvider.Generate(); // or new(kek)... byte[] container = await new PqFileEncryptor().EncryptBytesAsync(secret, provider); byte[] plaintext = await new PqFileDecryptor().DecryptBytesAsync(container, provider); -// ...or keep the master key in AWS KMS / Azure Key Vault — rotation re-wraps the small -// content key; the multi-gigabyte payload is never re-encrypted: +// ...or keep the master key in AWS KMS / Azure Key Vault / Google Cloud KMS — rotation +// re-wraps the small content key; the multi-gigabyte payload is never re-encrypted: var kmsProvider = new AwsKmsContentKeyProvider(new AmazonKeyManagementServiceClient(), "alias/my-app-key"); await new PqFileEncryptor().EncryptFileAsync("backup.tar", "backup.tar.pqfe", kmsProvider); ``` @@ -484,7 +550,7 @@ Be clear-eyed about what *post-quantum* means here today: - **What's stable now:** the symmetric, passphrase-based engine. AES-256 is quantum-resistant for the *confidentiality of your data* (≈128-bit security under Grover), so a passphrase-encrypted file is sound against a harvest-now-decrypt-later - adversary. This is the engine being finalized for `1.0`. + adversary — production-ready since `1.0`. - **What's the recommended public-key path:** the **`PostQuantum.FileEncryption.Hybrid`** package — a **hybrid X25519 + ML-KEM-768 combiner** plus **multiple recipients**. Fully managed (BouncyCastle for *both* primitives), so it runs **anywhere** with no native @@ -494,6 +560,10 @@ Be clear-eyed about what *post-quantum* means here today: (`PqKeyPair`, `PqRecipientPublicKey`, `PqRecipientPrivateKey`, recipient overloads on `PqFileEncryptor`/`PqFileDecryptor`). Marked `[Obsolete]` with diagnostic id `PQFE002` since `1.0.0-rc.2`, kept for source-compatibility only. Migrate to the Hybrid package. +- **What happens when the algorithms have to change:** ML-KEM-1024, X-Wing, a second + AEAD — the registry mechanism, the migration guarantee for existing ciphertext, and the + harvest-now-decrypt-later reasoning are stated plainly in + [docs/CRYPTO-AGILITY.md](https://github.com/systemslibrarian/postquantum-file-encryption/blob/main/docs/CRYPTO-AGILITY.md). ```bash dotnet add package PostQuantum.FileEncryption.Hybrid --version 1.5.0 diff --git a/SECURITY.md b/SECURITY.md index 25ed2c8..c0008c6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,7 +12,7 @@ any incompatible change requires a `2.0` major version. | Version | Supported | Notes | | ------------------ | --------- | -------------------------------------------------------- | -| `1.0.x` (incl. rc) | ✅ | Current line. Security fixes land here. | +| `1.x` (current: `1.5.0`) | ✅ | Current line. Security fixes land here. | | `0.x` | ❌ | Pre-`1.0`. Format was not yet frozen; please upgrade. | A file produced by any `1.x` build is readable by every other `1.x` build. There is no diff --git a/docs/AUDIT-SCOPE.md b/docs/AUDIT-SCOPE.md index da7bd06..9fc202e 100644 --- a/docs/AUDIT-SCOPE.md +++ b/docs/AUDIT-SCOPE.md @@ -70,9 +70,10 @@ Not because they don't matter — because they are third-party, not-our-code, or - **Cryptographic primitives.** AES-GCM, PBKDF2, HKDF, SHA-512, platform ML-KEM (.NET BCL); ML-KEM/X25519/Ed25519/ML-DSA-65 (BouncyCastle); Argon2id (Konscious). Inventory and versions: [SECURITY-ARCHITECTURE.md](SECURITY-ARCHITECTURE.md). This library composes them. -- **Cloud provider internals.** `PostQuantum.FileEncryption.Aws` and `.AzureKeyVault` wrap - vendor SDKs; the SDKs and live-service semantics are out of scope. The **binding logic** - (encryption context / pinned key id + algorithm) is in scope as part of the envelope seam. +- **Cloud provider internals.** `PostQuantum.FileEncryption.Aws`, `.AzureKeyVault`, and + `.Gcp` wrap vendor SDKs; the SDKs and live-service semantics are out of scope. The + **binding logic** (encryption context / pinned key id + algorithm / AAD + CRC32C + verification) is in scope as part of the envelope seam. - **The Rust → WASM core** (`samples/pqfe-wasm`) is a second implementation of the same format, held byte-compatible by cross-implementation tests + a live interop CI job. It is *reference for* the format, not the primary audit subject; its own dependency audit runs in diff --git a/docs/COOKBOOK.md b/docs/COOKBOOK.md index 98c8bcd..3cb8a31 100644 --- a/docs/COOKBOOK.md +++ b/docs/COOKBOOK.md @@ -202,13 +202,14 @@ The public-key design matters operationally: a compromised web server can *add* cannot read any of them. For decryption endpoints serving user-supplied containers, use `new PqHybridDecryptor(PqDecryptionLimits.Untrusted)` (recipe 2). -## 7. Envelope encryption with a KMS (AWS / Azure) +## 7. Envelope encryption with a KMS (AWS / Azure / Google Cloud) The master key lives in the KMS/HSM and never enters your process; each file gets a fresh content key that the KMS wraps. Rotation re-wraps 32 bytes instead of re-encrypting terabytes. ```csharp // dotnet add package PostQuantum.FileEncryption.Aws +// (or .AzureKeyVault / .Gcp — same seam, swap the provider line) using var provider = new AwsKmsContentKeyProvider(kmsClient, keyId); await new PqFileEncryptor().EncryptFileAsync("data.bin", "data.pqfe", provider); diff --git a/docs/CRYPTO-AGILITY.md b/docs/CRYPTO-AGILITY.md new file mode 100644 index 0000000..ef2a531 --- /dev/null +++ b/docs/CRYPTO-AGILITY.md @@ -0,0 +1,74 @@ +# Crypto-Agility — What Happens When the Algorithms Have to Change + +The unspoken question behind every post-quantum library choice: *when ML-KEM-768 needs to +become ML-KEM-1024, when the combiner should become X-Wing, when AES-GCM needs a sibling — +what happens to my files?* This page answers it in one place. The mechanics live in +[VERSIONING.md](VERSIONING.md), [ROADMAP-2.0.md](ROADMAP-2.0.md), and +[HYBRID-COMBINER.md](HYBRID-COMBINER.md); this is the forward story they add up to. + +## The mechanism: versioned bytes, additive registries + +Agility here is structural, not aspirational: + +- **Every container leads with a `FormatVersion` byte** (currently `2`). A reader rejects + versions it does not understand — it never guesses. +- **Key establishment is a registry, not a hardcode.** The header's `KeySource` byte and + the hybrid block's `KemId` byte exist precisely so that new algorithms are **new + values**, which old readers reject fail-closed — never mutations of existing ones. + Adding a `KeySource` is non-breaking at the format level and ships as a `1.x` minor; + that is exactly how the Hybrid path, the Signing sidecar, and the `PQKF` key file all + shipped without touching a single frozen byte. +- **The frozen format is pinned by force, not policy:** byte-exact known-answer vectors + ([TEST-VECTORS.md](TEST-VECTORS.md), committed as binaries in + [`test-vectors/`](../test-vectors/)) are checked by two independent implementations on + every change. An accidental algorithm or layout change cannot pass CI. + +## The concrete upgrade paths + +**ML-KEM-768 → ML-KEM-1024, or a new component algorithm.** A new `KemId` / `KeySource` +value — additive, shippable in `1.x` if it only *adds* bytes readers can reject. The +adoption bar ([HYBRID-COMBINER.md](HYBRID-COMBINER.md)): a finished standard plus a +maintained .NET implementation. This library composes primitives; it never implements them. + +**The combiner → X-Wing or HPKE.** Same bar, but a combiner change alters what existing +bytes *mean*, so it is a format-v3 event, considered in the order of preference documented +in HYBRID-COMBINER.md (X-Wing as published → HPKE with a standardized hybrid KEM → +component swaps). + +**A second AEAD (ChaCha20-Poly1305), embedded signatures, metadata protection.** All +change the container layout → format v3, package `2.0`. The candidate set is maintained in +[ROADMAP-2.0.md](ROADMAP-2.0.md); the [KNOWN-GAPS.md](../KNOWN-GAPS.md) ledger is where +breaking improvements wait so they ship deliberately, together, once. + +## The guarantee your existing ciphertext gets + +- **Within `1.x`: no migration, ever.** The freeze is the migration policy. A file + encrypted by any `1.x` build opens with every other `1.x` build, in either + implementation. +- **Across `2.0`:** the new major ships **documented migration tooling** (a + rewrap/transcode path — re-wrapping the small content key, not re-encrypting terabytes), + and the last `1.x` minor keeps receiving security fixes for **at least 12 months** after + `2.0` tags ([SUPPORT.md](../SUPPORT.md)). Your v2 files do not rot on a schedule. + +## The harvest-now-decrypt-later answer, plainly + +Data confidentiality never rests on one assumption: + +- The **data plane is AES-256-GCM** — ≈128-bit security against a Grover adversary, + independent of any KEM. A passphrase-encrypted file's PQ resistance needs no algorithm + migration at all. +- The **recipient path is hybrid**: an attacker recording ciphertext today needs to break + **both** X25519 **and** ML-KEM-768 to ever recover the content key. +- If a component *is* broken someday, the failure is contained to the key-establishment + layer, the registry mechanism above carries the replacement, and the rewrap path + re-protects existing files without touching their payload bytes. + +## What will never change + +Format versions change bytes, not posture. The [non-negotiable +principles](../CLAUDE.md) hold across every version: no homegrown cryptography, +authenticated encryption only, fail closed with no oracles, transparency over reassurance. + +--- + +*To God be the glory — 1 Corinthians 10:31.* diff --git a/docs/KEY-MANAGEMENT.md b/docs/KEY-MANAGEMENT.md index 22e42ca..14a9436 100644 --- a/docs/KEY-MANAGEMENT.md +++ b/docs/KEY-MANAGEMENT.md @@ -3,10 +3,12 @@ The **envelope-key seam is implemented** (`KeySource = 5`): `IContentKeyProvider` plus the built-in, dependency-free `LocalKekContentKeyProvider`. **Cloud providers are SHIPPED** as separate packages: [`PostQuantum.FileEncryption.Aws`](https://www.nuget.org/packages/PostQuantum.FileEncryption.Aws) -(AWS KMS `GenerateDataKey`/`Decrypt` with a bound encryption context) and +(AWS KMS `GenerateDataKey`/`Decrypt` with a bound encryption context), [`PostQuantum.FileEncryption.AzureKeyVault`](https://www.nuget.org/packages/PostQuantum.FileEncryption.AzureKeyVault) -(Key Vault / Managed HSM wrap/unwrap, pinned key id and algorithm). HashiCorp Vault and -PKCS#11 remain future work. +(Key Vault / Managed HSM wrap/unwrap, pinned key id and algorithm), and +[`PostQuantum.FileEncryption.Gcp`](https://www.nuget.org/packages/PostQuantum.FileEncryption.Gcp) +(Cloud KMS `Encrypt`/`Decrypt` with bound AAD and end-to-end CRC32C checks). HashiCorp Vault +and PKCS#11 remain future work. ## Envelope encryption with an external provider (IMPLEMENTED) @@ -42,6 +44,11 @@ var aws = new AwsKmsContentKeyProvider(new AmazonKeyManagementServiceClient(), " var akv = new AzureKeyVaultContentKeyProvider( new CryptographyClient(new Uri("https://my-vault.vault.azure.net/keys/pqfe-kek/"), new DefaultAzureCredential())); + +// Google Cloud KMS — Encrypt/Decrypt; CEK generated locally (Cloud KMS has no server-side +// data-key generation), wrap bound to the CryptoKey and library AAD, CRC32C verified both ways: +var gcp = new GcpKmsContentKeyProvider(await KeyManagementServiceClient.CreateAsync(), + "projects/my-project/locations/global/keyRings/my-ring/cryptoKeys/pqfe-kek"); ``` - The master key stays in the KMS/HSM; only the per-file CEK crosses the boundary, wrapped. diff --git a/docs/TEST-VECTORS.md b/docs/TEST-VECTORS.md index 51bc776..a267259 100644 --- a/docs/TEST-VECTORS.md +++ b/docs/TEST-VECTORS.md @@ -14,6 +14,9 @@ The two implementations validate each other: the Rust core decrypts the .NET-pro and the .NET library decrypts the Rust-produced vector. CI runs both suites on every change. All containers are shown as **Base64**. See [FILE-FORMAT.md](FILE-FORMAT.md) for the byte layout. +The same bytes are committed as **ready-to-use binary artifacts** in +[`test-vectors/`](../test-vectors/) at the repository root, with pinned SHA-256 sums and a +30-second verification walkthrough — no decoding required. --- diff --git a/src/PostQuantum.FileEncryption.Hybrid/README.md b/src/PostQuantum.FileEncryption.Hybrid/README.md index c9da5f0..2311b31 100644 --- a/src/PostQuantum.FileEncryption.Hybrid/README.md +++ b/src/PostQuantum.FileEncryption.Hybrid/README.md @@ -81,6 +81,14 @@ await new PqHybridEncryptor().EncryptFileToAsync("report.pdf", "report.pqfe", re // Any one of alice/bob/carol can decrypt with their own private key. ``` +**Up to 55 recipients per container.** The limit is not arbitrary: each recipient entry is +1,186 bytes (the ML-KEM-768 ciphertext dominates it), and the frozen `.pqfe` v2 header +carries all entries in a length field capped at 65,535 bytes — ⌊65,534 / 1,186⌋ = 55. The +limit is enforced *before* any wrapping work, and widening the field is banked as a +format-v3 candidate ([KNOWN-GAPS.md](https://github.com/systemslibrarian/postquantum-file-encryption/blob/main/KNOWN-GAPS.md)). +For genuinely larger audiences, wrap to a group key held in a KMS via `IContentKeyProvider` +instead — access-group membership then lives where it can be revoked. + File and stream APIs (`EncryptFileAsync`, `EncryptAsync`, `DecryptFileAsync`, `DecryptAsync`) are also available, with atomic file output and progress reporting. diff --git a/test-vectors/README.md b/test-vectors/README.md new file mode 100644 index 0000000..5a09c09 --- /dev/null +++ b/test-vectors/README.md @@ -0,0 +1,63 @@ +# Committed Test-Vector Artifacts — verify in 30 seconds + +These are the **known-answer vectors from [docs/TEST-VECTORS.md](../docs/TEST-VECTORS.md) +as ready-to-use binary files** — the same bytes, Base64-decoded, committed so that "the +.NET and Rust implementations produce identical output" is something you can check, not +something you have to take on faith. They are frozen with the `.pqfe` v2 format for the +entire `1.x` line: these files must never change, and a CI test +(`VectorArtifactTests`) fails if they do. + +| File | Vector | What it pins | +| --- | --- | --- | +| `passphrase-pbkdf2.pqfe` | 1 | Passphrase container, PBKDF2-HMAC-SHA256 | +| `passphrase-argon2id.pqfe` | 2 | Passphrase container, Argon2id (cross-implementation KDF agreement) | +| `passphrase-pbkdf2-rustcore.pqfe` | 3 | Produced by the **Rust core**, read by .NET | +| `keyfile.pqkf` | 5 | `PQKF` v1 encrypted key file framing | +| `hybrid-recipient.pqfe` | 6 | X25519 + ML-KEM-768 hybrid wrap block and combiner | + +## Verify in 30 seconds + +The three passphrase vectors open with the `pqfe` CLI and the **published** passphrases — +no code required: + +```bash +dotnet tool install -g PostQuantum.FileEncryption.Tool + +PQFE_PASS='test-vector-passphrase' \ + pqfe decrypt passphrase-pbkdf2.pqfe vector1.txt --passphrase-env PQFE_PASS +cat vector1.txt +# → PostQuantum.FileEncryption known-answer vector v2. +``` + +(Vector 2 uses the same passphrase; vector 3 uses `cross-impl-passphrase` and was +*encrypted by the Rust implementation* — decrypting it with the .NET tool is the +cross-implementation proof in one command.) + +Check the artifacts themselves against their pinned hashes: + +```bash +sha256sum -c SHA256SUMS +``` + +The hybrid and key-file vectors need private keys / typed importers, so they are verified +by the test suites instead: + +```bash +dotnet test --filter "FullyQualifiedName~KnownAnswerVector|FullyQualifiedName~VectorArtifact" +cd samples/pqfe-wasm && cargo test # the independent Rust implementation, same bytes +``` + +## Provenance + +Each file decodes byte-for-byte from the Base64 in +[docs/TEST-VECTORS.md](../docs/TEST-VECTORS.md) (the normative copy), and is byte-identical +to the fuzzing seed corpus (`fuzz/PostQuantum.FileEncryption.Fuzz/seed-corpus/`). The +expected plaintexts, KDF parameters, and (where applicable) private keys are all published +in that document — every key here was generated solely for its vector and protects nothing. + +A change to any of these files is a **breaking format change** and cannot happen inside +`1.x` — see the freeze rules in [docs/VERSIONING.md](../docs/VERSIONING.md). + +--- + +*To God be the glory — 1 Corinthians 10:31.* diff --git a/test-vectors/SHA256SUMS b/test-vectors/SHA256SUMS new file mode 100644 index 0000000..2a7eeff --- /dev/null +++ b/test-vectors/SHA256SUMS @@ -0,0 +1,5 @@ +a16ff8db3dad6a50d9a81cee5a97ce26d875c8dce80a00c93dd7516f080d31de *hybrid-recipient.pqfe +4e165d1238fcad436bad8b7cd72072b9196e4492aaddfcddfbc82029f0eca4ee *passphrase-argon2id.pqfe +ab32cc1d2f5f673d77d80fc2f45307abe4a33a35552f2b5c677a9c5818718547 *passphrase-pbkdf2.pqfe +b428f6492c78fe03b8b3197872e60bd737764be066cdaddab594f06f18e6ade6 *passphrase-pbkdf2-rustcore.pqfe +eeda08e328b028e69f87145642c7898c72be83e410ebfd595f0b2b50fd9bfb38 *keyfile.pqkf diff --git a/test-vectors/hybrid-recipient.pqfe b/test-vectors/hybrid-recipient.pqfe new file mode 100644 index 0000000000000000000000000000000000000000..65a2895f45609f280052f97117fe1288b6bc1e0e GIT binary patch literal 1308 zcmV+%1>^cqQAR}q0RsR40RRAOpLj6@p8*6wrv>^L`>?UZ6O{ho(~_lD?7uTfQt$uc zq{nQhBYILIaxUz9+^lx$x-#j4fF68b{LMF$7tCZ5AWWbHTPvH+CbsClv}c}aGTrYR zZ1V{!iRdg0eZd3*rr_!PkcuxF2pppst6K0>@8f3Ea1OgW-xwYuM2g_Bl(hj3sFaCO zZB6a$jT#dvW#5S%Q^mAY1>eR|{ z37k8yfcp+Tur0ob{|WW|VWJ*f4&eD0e?dX)(fr%yGEg_L!$_e^tI+tCUKj?HF9+6p zCiZ!B|2Xp9a9aOydQ>oc^3Ki_@vXiK_(JF5fIp}9N3WbMvtGcQhXxnBi=hr^ImsX8 zBv@7D(`$W=^FNC%TmoGukir5I0DE)0l@S4CwkI1l@!a{=SO8s4$-JW|jv)eLpHQ9o$dT9-NuN{rFb3F`QAda=t zfoY`(K&3)Lq|72<5NU zC2U;nksnI{g^7KMrWV8cbkRdQI~=U*T0G#X(A?eTKR-O04;#-!>Ws|MWu->UCaa!g zD*EgZx@D5oJ5Lrkh9|iplhA9LzF^P|ZxD0-OF6|$On68DluJXvm_#a4?ec-sH30ax z_Y@5tV!1K3#6XCCXtslk)8bY~w5B#WugB=A8Ec4%=Tty3TIR>0Hl(7vFqn-W*iq=F zudg~zr9t?0(cn9q=~|91!TY_5kNJ;zsDgY8rNH#>(lqJl*ttabUjBUt zD{-h?;B;eQ#Yltmikw_L9D%uTRuR(lKX9$A0uelcU^t3Be-dVY2SJ>J)X^p7fuQ(D z5F44e@`hw|Uk>Gp9L_~+v{Qfz-Ey4kgwC%5Wr5jGMM(gK0|=n)Rgxqvpf16Pgy~4M zRQ@8$`o=-aEd@E++J}A)to7GXo=PMIJpCPYfWGoLqa^bgVC%DhXDmGH5(qVb%@?Y_ z#LQD+%BsuUJ^pHq)voS5;b3s`PMwx;=8%^z2cnXrR;9a~P&$g_ry+?>r$L#;KzKG2 zvoT5mTFVihg(C)*C;uQN(U;O#U*8VDR84n0@@o%J?Qj)=t;^`gZqa;^E_=42=3Y%- z?VFL2=jg15##-udV>*`#@@HznyJkRr$>^mXq7JR%;irMQ=nC}G9C-d%-t8};6|jNC zM}hYRKUY8Eh+=8d$1|jC?GY9bX|rcVN`o;Lt+3HTsawWeRG6OlPr%jnw@3{RfC^Rn z^W<`70cz^RvGrR5#+Rl5o5l;`N|o{z_S;#K*LawgU;{!_H-l;3o$$tST_&?FQdu}x z{98+amk0WGF=n!i5a`T`)2D5aViEUlo_M=|m0}=q7O=32vi!%6h5L^l(EcRCx1K1{ zz{!tzwE8AIS-zZZwAtt(tXq*HZ~9bfvF%h%hCwI*bHmLOlWPZ#0aYou`Y{{zK5}bFulKKs^t^x)N{{olli`XmT+2>|T(Nd{Yc7xLg$LrF zA9Au{$I^c-xqG1v#4ZKd6|n-^KvuieDusHsK5Es%CY`u9I< SI0&J@ZZ_=|dbOTT;4X3SiHzL< literal 0 HcmV?d00001 diff --git a/test-vectors/keyfile.pqkf b/test-vectors/keyfile.pqkf new file mode 100644 index 0000000000000000000000000000000000000000..640664ea45672281584ea79f3b8c7bad861c5e5a GIT binary patch literal 2504 zcmV;(2{-moQAP6yd4YR4qSY*)1rrB3tJ+0ZZ~?f zV)8az(uJ_w>SPpqqqo;vJU!6#5KQqLv16`HdovZ2Bx$yCPsJYnHG|5Bdu*ze9?Q0+ z(cspUt~RiK&p@9-FNe$&AC88_JC)U|Y9k+Q$`C{=(~zs^W2*L%XoqSmXNm_WUEG^r<_m1s7Q_`WaH&+TlGhawAJ)0$&Ll=dfA-ANUUs zj#0uXc|kPy$*Ovw^Pnj;7%+k~3JkGurCWDWZfmJ*?tf5cjY#mKMfE0WRMBx^8@eGi6eGnLa!AOH{}X)@a1a7wz`e z!ArC+F{@tU44f3tBgyjnPIO7bU)MF4g(_+s<+&C~TK|Knjig?$HK0Bpdw4pMFx%V! zm-lr@oml1t1Trmmjd-MgIo3VvYmNV}P+sE#Qm7R#ier}cIpiDnv9AKToA;DMLs}B9 z#PPe8@#AvQ`gg5lG=iXY%;^FcNaF$Vlc1CJGqw}>{wH1!lA(~Rn5fvDD3&YToWc3D z++aQL;ODNu)CI{F7`|AL7{w}^0tJMuq|VbCOsVfBdcM^<4EqfTK8Ca141q=SZ8j! z0&&QnMzda-t@|5bSSqZs1ycrQAy&Y*Lzpk8p_g>=p!H6pl!Ie;Ylvy2-tT0&>?SlP zqkJMvNQ3dQ))ee7MwB~n!Ni!MI0^U3)o)#kZ|B=B4GbB9*Tc7Yvc3gDubE8_Ej~rP zBG+Ets4bBsW~kW!EGalZuFr5j=10L zyb!5MI&{;M|Btju2Zy2>Wnxr95w{r}T!EV_wD1iD#*ISda_TXLFA83%oYK6Gd#vF9 znZ7A-aw#IYkW!QJHWrSn7=t+u3BJ6rS4>|8d&+|QS+9aS=tj@6=?1;yq+&8DBw-p> ztK-hnM@UFY!OsW~@QHlD)NQ(iXVQN)JPktl6~}?JT1iqIRdK(K7M}22` z_ad)@{mkn?~hP5Wc%^Y!?U%pnw1`|h1&U1a3JfY zeTO*j#FWmVDx3Mu@JimsgYsv8`SceSb7EP=Lq@gLxfUFWH4W->INbF53+^Hy3&%!P zY+FcrjVlEt7dA5^tr5XL4Xqr@4IfT+?;j>^yZr=zM&VCt>#jYI3$c}-iWi3WB}YOc zAl`=+gC9}79SJFh2k&(l4#9dk*3YH68$=}CUVoi6LUQzX(YJh1NX2qoT3#yVS`f6I zK&KL8vqgm5K>#AKjFWngFOn*I%2csp1>@xpK)Ke;0cN*IghP!!c*aoAG%Dm zjy|aQ6l+M>_Kaxz2YjYgwiX^yT|^(}n*du(ovZ&`U}k7_T?t4t%Wkud5v2)yFQgGS z8}Um8{MU$IG#miKPbN~dn-bco*FOz-h*Pk}*pJov|2781Y2Qj4ylvS@w~v)2>d885 zvoTQo1u(2db6@Ty^bdsDk~>z<{O=gf!;;LP(sz`+@aC$#l<=^fW!;BkII;Afm$AO9 z2lbmezPdN~lG%Ja2`m#)saB&000=V;=vB6G!-J&aLLkB*B17us;Dzfn+4ThC$Uxbj zvax+8jZTj-1G$*z9dS))o}~}xO)u$IkUrCu?bP=0ZRgUMy2BTiz%Hji!Iei!xc6h&Nm9kiYQ?{BD z%m#G4ycEpNHeb&jsKV9$%k<$I!*sG24J5D3uu|F4%9h50cBxP$SF0-e#=X83(Y;&- z07GLrnHzU7JTvNVtU#UgyfStH>_Hi1m&R&xrJCqt=p&_+jNE??>xO!yjYLGo7w(4@ z*F$j1CHZ$n`D~Cd9$#@~XH+?_tI%$-taH$BV!vxkF>wd}?1u4MrwwHP;#5kz-loY7 zs#c%Y6iOWhmt*tqy+iol({YeC1&%JeevV@W+?ZPbo1GOsT{e zy}_xkrB!@ca0#2SH=cdIObWO{0+{JsQ0)+d-&N7$=OKB8a&Rxu}i3*&1N2?d)vD zs#S&QMA&KdW=!TdVUdw_&Ac-JL(9g2lLpVGHKEnMAPQ8rg;ar&L!PMZHA{lI6|>;JaCAhjZe6g$IAk(_NgTO) z>#x1iu}dOdQd5f!{yMa0?nWo`7I2G) SrA)qnZ}}e;w!iz4{>SVwecht~ literal 0 HcmV?d00001 diff --git a/test-vectors/passphrase-argon2id.pqfe b/test-vectors/passphrase-argon2id.pqfe new file mode 100644 index 0000000000000000000000000000000000000000..d0219fb7f3547cfd2bf28a700ea994ea88572be1 GIT binary patch literal 116 zcmWFtbaQ25WMp7qU}5myHrXJ7;?Gx9sT zLb`wU74xg@p*dT(Z|eOwaj*5X*_V61D7-T~8JF*J#@RgOO)TT2ZJUdtr#HRdt>QMP UBub!!MQ_rvllSLy#I0!t0La5Ff&c&j literal 0 HcmV?d00001 diff --git a/test-vectors/passphrase-pbkdf2-rustcore.pqfe b/test-vectors/passphrase-pbkdf2-rustcore.pqfe new file mode 100644 index 0000000000000000000000000000000000000000..134aa7640966df608d62420bfce8ac89faa2a319 GIT binary patch literal 112 zcmV-$0FVDrQAR}q0RaF20RRAsMjJW+76A~zLPA=a-1j?ywkyp7<(pdo2`9h-0000p zTP5u0vu`GMIAanN%p*x#>_qg8mw65ox040EJPZqN#{4?+=nwd9*q(Wrsa+)8Hp=dp Sx^mAqlOVDVhnI$j>CyH?h%M#- literal 0 HcmV?d00001 diff --git a/test-vectors/passphrase-pbkdf2.pqfe b/test-vectors/passphrase-pbkdf2.pqfe new file mode 100644 index 0000000000000000000000000000000000000000..fbf4130714f667ac473fd068e56ce90ae2b25580 GIT binary patch literal 111 zcmV-#0FeJsQAR}q0RaF200aOgj-m1Z76A}f)WK@286_T0dW6bD{8Nqq0fwLf0000o zkN3~r5exz7Vf9%G +/// Guards the committed vector artifacts (test-vectors/ at the repository root) +/// against drift: those bytes are the published known-answer vectors and are frozen with +/// the v2 format for the entire 1.x line. A failure here means an artifact was edited or +/// regenerated — revert the artifact; never update these hashes inside 1.x. +/// +public sealed class VectorArtifactTests +{ + [Theory] + [InlineData("passphrase-pbkdf2.pqfe", "AB32CC1D2F5F673D77D80FC2F45307ABE4A33A35552F2B5C677A9C5818718547")] + [InlineData("passphrase-argon2id.pqfe", "4E165D1238FCAD436BAD8B7CD72072B9196E4492AADDFCDDFBC82029F0ECA4EE")] + [InlineData("passphrase-pbkdf2-rustcore.pqfe", "B428F6492C78FE03B8B3197872E60BD737764BE066CDADDAB594F06F18E6ADE6")] + [InlineData("keyfile.pqkf", "EEDA08E328B028E69F87145642C7898C72BE83E410EBFD595F0B2B50FD9BFB38")] + [InlineData("hybrid-recipient.pqfe", "A16FF8DB3DAD6A50D9A81CEE5A97CE26D875C8DCE80A00C93DD7516F080D31DE")] + public void Committed_vector_artifact_is_byte_identical(string fileName, string expectedSha256) + { + string path = Path.Combine(FindRepositoryRoot(), "test-vectors", fileName); + byte[] bytes = File.ReadAllBytes(path); + + Assert.Equal(expectedSha256, Convert.ToHexString(SHA256.HashData(bytes))); + } + + private static string FindRepositoryRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "test-vectors"))) + { + dir = dir.Parent; + } + return dir?.FullName + ?? throw new InvalidOperationException( + $"No 'test-vectors' directory found above {AppContext.BaseDirectory}."); + } +} From 48771138500bafad70055e95e908b8ea4a05fb36 Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Fri, 10 Jul 2026 19:44:34 -0400 Subject: [PATCH 4/7] feat: native CLI binaries and differential round-trip coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two developer-experience additions for people building on post-quantum crypto. - Native single-file `pqfe` binaries (linux-x64, win-x64, osx-arm64) are built natively per target in the release pipeline and attached to each release with a SHA-256 sum and a build-provenance attestation — usable with no .NET runtime. - A Rust round-trip property test sweeps the framing matrix (chunk sizes × lengths straddling one/two/three chunks, plus per-byte tamper and truncation at every length), catching encode/decode asymmetry the fixed KATs cannot. The cross-implementation CI harness now randomizes payload sizes each run, so the .NET <-> Rust agreement is exercised at fresh points continuously. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 7 +- .github/workflows/release.yml | 73 +++++++++++++++++++++ CHANGELOG.md | 11 ++++ README.md | 6 ++ samples/pqfe-wasm/tests/roundtrip.rs | 97 ++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 samples/pqfe-wasm/tests/roundtrip.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41f2ac8..72d57a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -177,8 +177,11 @@ 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 903d8b5..31ac2d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -253,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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 07b3369..fece5f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,17 @@ and the `.pqfe` v2 container format is frozen for the entire `1.x` line. 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.** diff --git a/README.md b/README.md index 38b7600..d190b4e 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,12 @@ pqfe sign report.pdf.pqfe me.key # writes report.pdf.pqfe. pqfe verify report.pdf.pqfe me.key.pub # exit 0 = authentic, 65 = reject ``` +**No .NET runtime? Download a standalone binary.** Each release attaches native, single-file +`pqfe` executables — `pqfe-linux-x64`, `pqfe-win-x64.exe`, `pqfe-osx-arm64` — each with a +SHA-256 sum and a build-provenance attestation you can verify with `gh attestation verify`. +The `encrypt`/`decrypt` commands refuse to overwrite an existing output unless you pass +`--force`. + The source lives at [`samples/Pqfe.Cli`](https://github.com/systemslibrarian/postquantum-file-encryption/tree/main/samples/Pqfe.Cli) and is built on the public API. It's also the canary that proves `IsAotCompatible=true` end-to-end: CI publishes it with `PublishAot=true` and round-trips a real file as the smoke test. diff --git a/samples/pqfe-wasm/tests/roundtrip.rs b/samples/pqfe-wasm/tests/roundtrip.rs new file mode 100644 index 0000000..0fde26b --- /dev/null +++ b/samples/pqfe-wasm/tests/roundtrip.rs @@ -0,0 +1,97 @@ +//! Round-trip (encode ↔ decode) property coverage for the Rust core. +//! +//! The pinned known-answer vectors ([`vectors.rs`]) prove the format is read *correctly*, and +//! the CI interop harness proves .NET and Rust agree on a handful of fixed sizes. Neither +//! sweeps the framing surface: the per-chunk nonce counter, the AAD chaining, and the +//! final-frame marker are all functions of `(chunk_size, data_len)`, and the bug they hide — +//! producing a container the *same* implementation cannot read back — is invisible to a fixed +//! vector. This test drives every interesting boundary of that surface: for each chunk size, +//! data lengths straddling one, two, and three chunks, plus the empty and single-byte cases. +//! +//! Every case must satisfy `decrypt(encrypt(x)) == x`; a mismatch or error means the encode and +//! decode halves disagree — exactly the asymmetry a round-trip check exists to catch. + +use pqfe_wasm::{decrypt_bytes, encrypt_bytes_with}; + +const PASSPHRASE: &[u8] = b"round-trip-property-passphrase"; +// The format's PBKDF2 floor; kept at the minimum so the framing matrix stays cheap. +const ITERS: u32 = 100_000; +// A fixed salt and nonce prefix keep the test deterministic — this exercises framing, not +// key derivation (the KATs pin the derivation). +const SALT: &[u8] = b"0123456789abcdef"; +const NONCE_PREFIX: &[u8] = b"\x00\x01\x02\x03"; + +/// A deterministic, non-trivial byte pattern of the given length (position-dependent so a +/// chunk-ordering bug corrupts the comparison rather than happening to match). +fn pattern(len: usize) -> Vec { + (0..len).map(|i| ((i * 31 + 7) & 0xFF) as u8).collect() +} + +fn assert_round_trips(data: &[u8], chunk_size: u32) { + let container = encrypt_bytes_with(data, PASSPHRASE, SALT, NONCE_PREFIX, ITERS, chunk_size); + let recovered = decrypt_bytes(&container, PASSPHRASE) + .unwrap_or_else(|e| panic!("chunk {chunk_size}, len {}: decrypt failed: {e:?}", data.len())); + assert_eq!( + recovered, + data, + "chunk {chunk_size}, len {}: recovered plaintext differs from input", + data.len() + ); +} + +#[test] +fn round_trips_across_the_framing_matrix() { + // MIN_CHUNK, a small non-power-of-two, and the default 64 KiB. + for &chunk in &[1024u32, 1500, 64 * 1024] { + let c = chunk as usize; + // Lengths straddling the empty case, the first chunk boundary, and multi-chunk. + let lengths = [ + 0, + 1, + c - 1, + c, + c + 1, + 2 * c - 1, + 2 * c, + 2 * c + 1, + 3 * c + 7, + ]; + for &len in &lengths { + assert_round_trips(&pattern(len), chunk); + } + } +} + +#[test] +fn tampering_with_any_frame_byte_fails_closed() { + // A round-trip that authenticates is only half the contract; the other half is that a + // single flipped byte anywhere never decrypts. Walk the whole container at a coarse stride + // (fine enough to hit the header, every frame's ciphertext, and every tag). + let data = pattern(3 * 1024 + 7); // multi-chunk at MIN_CHUNK + let mut container = encrypt_bytes_with(&data, PASSPHRASE, SALT, NONCE_PREFIX, ITERS, 1024); + let original = container.clone(); + + for i in (0..container.len()).step_by(17) { + container[i] ^= 0x01; + assert!( + decrypt_bytes(&container, PASSPHRASE).is_err(), + "flipping byte {i} still decrypted — fail-closed violation" + ); + container[i] = original[i]; + } +} + +#[test] +fn truncation_at_every_length_fails_closed() { + // Every proper prefix of a valid container must be rejected — the anti-truncation guarantee. + let data = pattern(2 * 1024 + 100); + let container = encrypt_bytes_with(&data, PASSPHRASE, SALT, NONCE_PREFIX, ITERS, 1024); + + for cut in (1..container.len()).step_by(29) { + assert!( + decrypt_bytes(&container[..cut], PASSPHRASE).is_err(), + "a {cut}-byte prefix of a {}-byte container decrypted — truncation not detected", + container.len() + ); + } +} From 5247ae5ca1bd3bd177c02bd2cc37f7c54be8401a Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Fri, 10 Jul 2026 19:51:27 -0400 Subject: [PATCH 5/7] docs: compare against general-purpose crypto toolkits; refresh recipient row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a fair "versus general-purpose crypto toolkits" section to COMPARISON.md — the broad BouncyCastle-wrapper libraries that expose ML-KEM/ML-DSA as primitives. Framed as a scope/altitude difference (primitives vs a finished fail-closed construction; no-footguns-by-omission; hybrid vs bare Kyber; a verifiable format vs a library), not open-vs-closed, since several are also MIT. Describes the category rather than naming specific packages, so it ages well. Also corrects the at-a-glance table, which still called the recipient path "experimental" — it is the shipped hybrid X25519 + ML-KEM-768 Hybrid package. Co-Authored-By: Claude Fable 5 --- docs/COMPARISON.md | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index 52db3d2..60eccf8 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -11,8 +11,8 @@ they target different ecosystems and trade-offs. Pick the one that fits. | Data cipher | AES-256-GCM | ChaCha20-Poly1305 | XChaCha20-Poly1305 | user-chosen (easy to misuse) | | Authenticated | Always | Always | Always | Only with `-aead` modes, opt-in | | Passphrase KDF | PBKDF2 or Argon2id | scrypt | (app's choice) | weak by default (legacy) | -| Public-key recipients | Experimental (ML-KEM-768) | Yes (X25519) | Via box APIs | No | -| Post-quantum | AES-256 data (quantum-safe); PQ KEM experimental | No | No | No | +| Public-key recipients | Yes — hybrid X25519 + ML-KEM-768 (Hybrid package), multi-recipient | Yes (X25519) | Via box APIs | No | +| Post-quantum | AES-256 data (quantum-safe); hybrid PQ recipients (X25519 + ML-KEM-768) | No | No | No | | Streaming / large files | Yes, chunked, bounded memory | Yes | Yes (designed for it) | Yes | | Anti-truncation / reorder | Yes (authenticated framing) | Yes | Yes | No (raw modes) | | Specified, vectored format | Yes (+ cross-impl byte-exact vectors) | Yes | N/A (API, not a file format) | N/A | @@ -36,6 +36,38 @@ they target different ecosystems and trade-offs. Pick the one that fits. - **OpenSSL `enc`** — generally **avoid** for new designs; it's easy to use in unauthenticated or weak-KDF modes. Prefer any of the above. +## Versus general-purpose crypto toolkits + +A different category worth naming: the broad **"one API for all of cryptography"** .NET libraries — +typically BouncyCastle wrappers that expose a large menu of primitives (many block and stream +ciphers, RSA, hashing, HMAC, TOTP, X.509 certificates, encodings) and, increasingly, the +post-quantum primitives **ML-KEM** and **ML-DSA** as standalone building blocks. Several are MIT +and open, so this is **not** an open-vs-closed distinction — it is a difference of **scope and +altitude**. + +- **Primitives vs a finished construction.** A toolkit hands you ML-KEM (and AES, and a KDF) and + leaves the composition to you: choosing an authenticated mode, combining KEM + DEM correctly, + managing nonces, deriving keys, framing the output. This library ships **one opinionated, + finished construction** — an authenticated, chunked, fail-closed file format with the hybrid + combiner already wired in. There is no unauthenticated path to select and no primitive to + misassemble. +- **No footguns by omission.** Comprehensive toolkits include legacy primitives (e.g. MD5, SHA-1, + DES, ECB mode) for completeness. This library deliberately ships none of them; every path is + AES-256-GCM with authenticated framing. Breadth is their feature; a small, safe surface is ours. +- **Hybrid, not bare Kyber.** Where a toolkit typically exposes ML-KEM alone, the recipient path + here is a **hybrid X25519 + ML-KEM-768 combiner** — confidentiality survives even if *either* + primitive is later broken ([HYBRID-COMBINER.md](HYBRID-COMBINER.md)). +- **A verifiable format, not just a library.** A toolkit is an API; this is a **frozen, publicly + specified container** ([FILE-FORMAT.md](FILE-FORMAT.md)) pinned by cross-implementation + byte-exact vectors and a second (Rust) implementation, with a published threat model and + supply-chain provenance. That is the layer a general-purpose library does not set out to provide. + +**When a general-purpose toolkit fits better:** you need breadth this library intentionally omits — +certificates, TOTP, RSA, standalone signature or KEM primitives, or a grab-bag of ciphers — or you +are deliberately assembling your own construction and want the building blocks rather than a +finished, frozen format. For **encrypting files against a quantum-capable adversary, safely and +verifiably**, a single-purpose tool that cannot be misused is the better fit. + ## Honest caveats This library is **younger** than age and libsodium and has **not been independently audited** From bfc98ebb4dab22103c88ccc2a7d438a077f2ecde Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Fri, 10 Jul 2026 20:02:26 -0400 Subject: [PATCH 6/7] =?UTF-8?q?release:=201.6.0=20=E2=80=94=20lockstep=20v?= =?UTF-8?q?ersion=20bump=20and=20doc=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump all nine lockstep packages to 1.6.0, promote the Gcp package's public API from Unshipped to Shipped, and set PackageValidationBaselineVersion to the previous release (1.5.0). Sweep every user-facing version reference: the root README Status line, all `dotnet add package --version` snippets (root + package READMEs), the supply-chain verify/download examples, and the ROADMAP-2.0 "NuGet package version" cell. Finalize the CHANGELOG [1.6.0] section and compare-link footer. Historical references (audit-scope tag/commit, coverage measured at v1.5.0) are left as the facts they are. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 ++++++++-- Directory.Build.props | 2 +- README.md | 20 +++++++++---------- docs/ROADMAP-2.0.md | 2 +- samples/Pqfe.Cli/Pqfe.Cli.csproj | 6 +++--- ...ostQuantum.FileEncryption.Analyzers.csproj | 6 +++--- .../README.md | 2 +- .../PostQuantum.FileEncryption.Aws.csproj | 6 +++--- ...uantum.FileEncryption.AzureKeyVault.csproj | 6 +++--- ...tion.Extensions.DependencyInjection.csproj | 6 +++--- .../PostQuantum.FileEncryption.Gcp.csproj | 6 +++--- .../PublicAPI.Shipped.txt | 5 +++++ .../PublicAPI.Unshipped.txt | 5 ----- .../PostQuantum.FileEncryption.Hybrid.csproj | 6 +++--- .../README.md | 2 +- .../PostQuantum.FileEncryption.Signing.csproj | 6 +++--- .../README.md | 2 +- .../PostQuantum.FileEncryption.csproj | 6 +++--- 18 files changed, 55 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fece5f0..f283c9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,13 @@ All notable changes to this project are documented here. The format is based on locked by `Microsoft.CodeAnalysis.PublicApiAnalyzers` baselines and ``, and the `.pqfe` v2 container format is frozen for the entire `1.x` line. -## [Unreleased] +## [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 @@ -760,7 +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. -[unreleased]: https://github.com/systemslibrarian/postquantum-file-encryption/compare/v1.5.0...HEAD +[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 diff --git a/Directory.Build.props b/Directory.Build.props index 6626526..2f1c168 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -18,7 +18,7 @@ are listed per project in CompatibilitySuppressions.xml. --> true - 1.3.0 + 1.5.0 true diff --git a/README.md b/README.md index d190b4e..bbe5172 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ careful, paranoid, fail-closed thing every time. And because the code, the forma specification, the test vectors, the threat model, and the gaps ledger are all public, you never have to take that on faith. -> **Status: `1.5.0` — stable release.** +> **Status: `1.6.0` — stable release.** > The symmetric, passphrase-based engine is production-ready and the `.pqfe` v2 container > format is **FROZEN for the `1.x` line**. The companion **`PostQuantum.FileEncryption.Hybrid`** > package provides production X25519 + ML-KEM-768 hybrid public-key encryption with @@ -116,13 +116,13 @@ minute: ```bash # Core (passphrase + envelope-key engine) -dotnet add package PostQuantum.FileEncryption --version 1.5.0 +dotnet add package PostQuantum.FileEncryption --version 1.6.0 # Add this only if you need public-key (recipient) encryption -dotnet add package PostQuantum.FileEncryption.Hybrid --version 1.5.0 +dotnet add package PostQuantum.FileEncryption.Hybrid --version 1.6.0 # Optional: detached Ed25519 + ML-DSA-65 signatures (sender authenticity) -dotnet add package PostQuantum.FileEncryption.Signing --version 1.5.0 +dotnet add package PostQuantum.FileEncryption.Signing --version 1.6.0 # Optional: cloud envelope-key providers (the master key stays in your KMS/HSM) dotnet add package PostQuantum.FileEncryption.Aws # AWS KMS @@ -131,11 +131,11 @@ dotnet add package PostQuantum.FileEncryption.Gcp # Google Cloud KMS # Optional: Microsoft.Extensions.DependencyInjection integration # (AddPqFileEncryption() / AddPqHybridFileEncryption()) -dotnet add package PostQuantum.FileEncryption.Extensions.DependencyInjection --version 1.5.0 +dotnet add package PostQuantum.FileEncryption.Extensions.DependencyInjection --version 1.6.0 # Recommended: compile-time misuse checks (hard-coded passphrases, raw keys on disk, # discarded tasks, swallowed fail-closed exceptions). Development-only dependency. -dotnet add package PostQuantum.FileEncryption.Analyzers --version 1.5.0 +dotnet add package PostQuantum.FileEncryption.Analyzers --version 1.6.0 ``` Targets **.NET 8 and .NET 10** (`net8.0`; `net10.0`), with an identical public API on @@ -242,7 +242,7 @@ None of this library's trust claims ask for faith — each one is checkable in a ```bash # 1. Provenance: the package you downloaded was built by this repo's public release # workflow, on GitHub's runners, from a tagged commit you can read: -gh attestation verify PostQuantum.FileEncryption.1.5.0.nupkg \ +gh attestation verify PostQuantum.FileEncryption.1.6.0.nupkg \ --repo systemslibrarian/postquantum-file-encryption # 2. The frozen format: reproduce the pinned known-answer vectors — byte-exact @@ -572,7 +572,7 @@ Be clear-eyed about what *post-quantum* means here today: [docs/CRYPTO-AGILITY.md](https://github.com/systemslibrarian/postquantum-file-encryption/blob/main/docs/CRYPTO-AGILITY.md). ```bash -dotnet add package PostQuantum.FileEncryption.Hybrid --version 1.5.0 +dotnet add package PostQuantum.FileEncryption.Hybrid --version 1.6.0 ``` ```csharp @@ -603,11 +603,11 @@ Quick verification of any release: ```bash # Verify the build-provenance attestation on a downloaded .nupkg: -gh attestation verify PostQuantum.FileEncryption.1.5.0.nupkg \ +gh attestation verify PostQuantum.FileEncryption.1.6.0.nupkg \ --owner systemslibrarian # Inspect the CycloneDX SBOM bundled with the release: -gh release download v1.5.0 -p 'sbom.core.cdx.json' && jq . sbom.core.cdx.json +gh release download v1.6.0 -p 'sbom.core.cdx.json' && jq . sbom.core.cdx.json # Confirm the conformance vectors decrypt locally: dotnet test --filter "FullyQualifiedName~KnownAnswerVector|FullyQualifiedName~CrossImplementation" diff --git a/docs/ROADMAP-2.0.md b/docs/ROADMAP-2.0.md index 5284ce2..b75a96e 100644 --- a/docs/ROADMAP-2.0.md +++ b/docs/ROADMAP-2.0.md @@ -11,7 +11,7 @@ There are two version tracks, and conflating them causes confusion: | Track | Today | At the next major | | --- | --- | --- | -| **NuGet package version** | `1.5.0` | `2.0.0` | +| **NuGet package version** | `1.6.0` | `2.0.0` | | **`.pqfe` container `FormatVersion`** (byte 5 of every file) | `2` | `3` | Format **v2 is what every `1.x`-encrypted file on disk carries today** — it is very much "out diff --git a/samples/Pqfe.Cli/Pqfe.Cli.csproj b/samples/Pqfe.Cli/Pqfe.Cli.csproj index 2beac88..3f28eb4 100644 --- a/samples/Pqfe.Cli/Pqfe.Cli.csproj +++ b/samples/Pqfe.Cli/Pqfe.Cli.csproj @@ -23,9 +23,9 @@ true pqfe PostQuantum.FileEncryption.Tool - 1.5.0 - 1.5.0.0 - 1.5.0 + 1.6.0 + 1.6.0.0 + 1.6.0 6.0.0 pqfe — PostQuantum.FileEncryption CLI Command-line file encryption and signing built on PostQuantum.FileEncryption: `pqfe encrypt` / `pqfe decrypt` for the FROZEN .pqfe v2 container format, plus `pqfe keygen` / `pqfe sign` / `pqfe verify` for detached Ed25519 + ML-DSA-65 hybrid signatures (.sig sidecars). Authenticated AES-256-GCM with PBKDF2-HMAC-SHA256 or Argon2id passphrase derivation, atomic output files, fail-closed decryption and verification (wrong passphrase, tampered ciphertext, and bad signatures are indistinguishable within their class), and sysexits.h exit codes for scripting. Passphrases come from a no-echo prompt or an environment variable for CI use. Install: dotnet tool install -g PostQuantum.FileEncryption.Tool. Requires the .NET 10 runtime or later. diff --git a/src/PostQuantum.FileEncryption.Analyzers/PostQuantum.FileEncryption.Analyzers.csproj b/src/PostQuantum.FileEncryption.Analyzers/PostQuantum.FileEncryption.Analyzers.csproj index 1dc1059..82ee787 100644 --- a/src/PostQuantum.FileEncryption.Analyzers/PostQuantum.FileEncryption.Analyzers.csproj +++ b/src/PostQuantum.FileEncryption.Analyzers/PostQuantum.FileEncryption.Analyzers.csproj @@ -18,9 +18,9 @@ PostQuantum.FileEncryption.Analyzers - 1.5.0 - 1.5.0.0 - 1.5.0 + 1.6.0 + 1.6.0.0 + 1.6.0 6.0.0 PostQuantum.FileEncryption.Analyzers Roslyn analyzers that catch dangerous misuse of the PostQuantum.FileEncryption family at compile time, before it ships: hard-coded passphrases (PQFE101), raw private-key bytes written to disk instead of a passphrase-protected key file (PQFE102), discarded encrypt/decrypt/sign/verify tasks whose authentication never completes or fails unobserved (PQFE103), and silently swallowed fail-closed exceptions (PQFE104). Real-world encryption failures come from misuse, not broken primitives — these rules put the library's fail-closed discipline into the IDE. Development-only dependency; adds nothing to your runtime output. diff --git a/src/PostQuantum.FileEncryption.Analyzers/README.md b/src/PostQuantum.FileEncryption.Analyzers/README.md index 6f0cd34..dd093a3 100644 --- a/src/PostQuantum.FileEncryption.Analyzers/README.md +++ b/src/PostQuantum.FileEncryption.Analyzers/README.md @@ -8,7 +8,7 @@ Real-world encryption failures overwhelmingly come from *misuse*, not broken pri library's API is fail-closed by design; these rules extend that discipline to the calling code. ```bash -dotnet add package PostQuantum.FileEncryption.Analyzers --version 1.5.0 +dotnet add package PostQuantum.FileEncryption.Analyzers --version 1.6.0 ``` A development-only dependency: nothing is added to your runtime output or your users' diff --git a/src/PostQuantum.FileEncryption.Aws/PostQuantum.FileEncryption.Aws.csproj b/src/PostQuantum.FileEncryption.Aws/PostQuantum.FileEncryption.Aws.csproj index 5492ce2..eeb6388 100644 --- a/src/PostQuantum.FileEncryption.Aws/PostQuantum.FileEncryption.Aws.csproj +++ b/src/PostQuantum.FileEncryption.Aws/PostQuantum.FileEncryption.Aws.csproj @@ -10,9 +10,9 @@ PostQuantum.FileEncryption.Aws - 1.5.0 - 1.5.0.0 - 1.5.0 + 1.6.0 + 1.6.0.0 + 1.6.0 6.0.0 PostQuantum.FileEncryption.Aws AWS KMS envelope-key provider for PostQuantum.FileEncryption, for .NET 8 and .NET 10. AwsKmsContentKeyProvider implements the IContentKeyProvider seam over AWS KMS GenerateDataKey/Decrypt: every file is encrypted under a fresh per-file content key that KMS wraps under your customer master key — the master key never leaves AWS. The wrap is bound to the configured key id and a library-specific encryption context, and unwrap fails closed (PqDecryptionException, no oracle) on any invalid or foreign ciphertext. Works with every PqFileEncryptor/PqFileDecryptor overload that accepts a key provider; rotation re-wraps the small content key instead of re-encrypting the file. Public API surface locked by Microsoft.CodeAnalysis.PublicApiAnalyzers; CycloneDX SBOM and SLSA-style build-provenance attestation on every release. diff --git a/src/PostQuantum.FileEncryption.AzureKeyVault/PostQuantum.FileEncryption.AzureKeyVault.csproj b/src/PostQuantum.FileEncryption.AzureKeyVault/PostQuantum.FileEncryption.AzureKeyVault.csproj index 3b3177a..1c8c298 100644 --- a/src/PostQuantum.FileEncryption.AzureKeyVault/PostQuantum.FileEncryption.AzureKeyVault.csproj +++ b/src/PostQuantum.FileEncryption.AzureKeyVault/PostQuantum.FileEncryption.AzureKeyVault.csproj @@ -10,9 +10,9 @@ PostQuantum.FileEncryption.AzureKeyVault - 1.5.0 - 1.5.0.0 - 1.5.0 + 1.6.0 + 1.6.0.0 + 1.6.0 6.0.0 PostQuantum.FileEncryption.AzureKeyVault Azure Key Vault / Managed HSM envelope-key provider for PostQuantum.FileEncryption, for .NET 8 and .NET 10. AzureKeyVaultContentKeyProvider implements the IContentKeyProvider seam over Key Vault wrap/unwrap (RSA-OAEP-256 by default): every file is encrypted under a fresh per-file content key wrapped by your Key Vault key — the key-encryption key never leaves the vault or HSM. Unwrap is pinned to the configured key id and algorithm and fails closed (PqDecryptionException, no oracle) on any invalid or foreign wrapped key. Works with every PqFileEncryptor/PqFileDecryptor overload that accepts a key provider; rotation re-wraps the small content key instead of re-encrypting the file. Public API surface locked by Microsoft.CodeAnalysis.PublicApiAnalyzers; CycloneDX SBOM and SLSA-style build-provenance attestation on every release. diff --git a/src/PostQuantum.FileEncryption.Extensions.DependencyInjection/PostQuantum.FileEncryption.Extensions.DependencyInjection.csproj b/src/PostQuantum.FileEncryption.Extensions.DependencyInjection/PostQuantum.FileEncryption.Extensions.DependencyInjection.csproj index c580b08..8125d15 100644 --- a/src/PostQuantum.FileEncryption.Extensions.DependencyInjection/PostQuantum.FileEncryption.Extensions.DependencyInjection.csproj +++ b/src/PostQuantum.FileEncryption.Extensions.DependencyInjection/PostQuantum.FileEncryption.Extensions.DependencyInjection.csproj @@ -10,9 +10,9 @@ PostQuantum.FileEncryption.Extensions.DependencyInjection - 1.5.0 - 1.5.0.0 - 1.5.0 + 1.6.0 + 1.6.0.0 + 1.6.0 6.0.0 PostQuantum.FileEncryption.Extensions.DependencyInjection Microsoft.Extensions.DependencyInjection integration for PostQuantum.FileEncryption, for .NET 8 and .NET 10. Adds AddPqFileEncryption(), AddPqHybridFileEncryption(), and AddPqSigning() extension methods that register PqFileEncryptor/PqFileDecryptor, PqHybridEncryptor/PqHybridDecryptor, and PqSigner/PqVerifier as singletons, with optional PqEncryptionOptions. Brings the core library (constant-memory streaming AES-256-GCM over the FROZEN .pqfe v2 container, PBKDF2-HMAC-SHA256 or Argon2id), the production X25519 + ML-KEM-768 hybrid package, and detached Ed25519 + ML-DSA-65 signatures into any host using the standard .NET service container — ASP.NET Core, Worker Services, console hosts. Public API surface locked by Microsoft.CodeAnalysis.PublicApiAnalyzers; CycloneDX SBOM and SLSA-style build-provenance attestation on every release. diff --git a/src/PostQuantum.FileEncryption.Gcp/PostQuantum.FileEncryption.Gcp.csproj b/src/PostQuantum.FileEncryption.Gcp/PostQuantum.FileEncryption.Gcp.csproj index ed13f31..a538d2c 100644 --- a/src/PostQuantum.FileEncryption.Gcp/PostQuantum.FileEncryption.Gcp.csproj +++ b/src/PostQuantum.FileEncryption.Gcp/PostQuantum.FileEncryption.Gcp.csproj @@ -10,9 +10,9 @@ PostQuantum.FileEncryption.Gcp - 1.5.0 - 1.5.0.0 - 1.5.0 + 1.6.0 + 1.6.0.0 + 1.6.0 6.0.0 PostQuantum.FileEncryption.Gcp Google Cloud KMS envelope-key provider for PostQuantum.FileEncryption, for .NET 8 and .NET 10. GcpKmsContentKeyProvider implements the IContentKeyProvider seam over Cloud KMS Encrypt/Decrypt: every file is encrypted under a fresh per-file content key that Cloud KMS wraps under your key-ring key — the master key never leaves Google Cloud. The wrap is bound to the configured CryptoKey and library-specific additional authenticated data, every request and response is verified end to end with the CRC32C integrity fields Cloud KMS provides, and unwrap fails closed (PqDecryptionException, no oracle) on any invalid or foreign ciphertext. Works with every PqFileEncryptor/PqFileDecryptor overload that accepts a key provider; rotation re-wraps the small content key instead of re-encrypting the file. Public API surface locked by Microsoft.CodeAnalysis.PublicApiAnalyzers; CycloneDX SBOM and SLSA-style build-provenance attestation on every release. diff --git a/src/PostQuantum.FileEncryption.Gcp/PublicAPI.Shipped.txt b/src/PostQuantum.FileEncryption.Gcp/PublicAPI.Shipped.txt index 7dc5c58..cdef0f4 100644 --- a/src/PostQuantum.FileEncryption.Gcp/PublicAPI.Shipped.txt +++ b/src/PostQuantum.FileEncryption.Gcp/PublicAPI.Shipped.txt @@ -1 +1,6 @@ #nullable enable +PostQuantum.FileEncryption.Gcp.GcpKmsContentKeyProvider +PostQuantum.FileEncryption.Gcp.GcpKmsContentKeyProvider.GcpKmsContentKeyProvider(Google.Cloud.Kms.V1.KeyManagementServiceClient! kms, string! cryptoKeyName, System.ReadOnlyMemory additionalAuthenticatedData = default(System.ReadOnlyMemory)) -> void +PostQuantum.FileEncryption.Gcp.GcpKmsContentKeyProvider.ProviderId.get -> string! +PostQuantum.FileEncryption.Gcp.GcpKmsContentKeyProvider.UnwrapKeyAsync(System.ReadOnlyMemory wrapInfo, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +PostQuantum.FileEncryption.Gcp.GcpKmsContentKeyProvider.WrapNewKeyAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<(byte[]! contentKey, byte[]! wrapInfo)>! diff --git a/src/PostQuantum.FileEncryption.Gcp/PublicAPI.Unshipped.txt b/src/PostQuantum.FileEncryption.Gcp/PublicAPI.Unshipped.txt index cdef0f4..7dc5c58 100644 --- a/src/PostQuantum.FileEncryption.Gcp/PublicAPI.Unshipped.txt +++ b/src/PostQuantum.FileEncryption.Gcp/PublicAPI.Unshipped.txt @@ -1,6 +1 @@ #nullable enable -PostQuantum.FileEncryption.Gcp.GcpKmsContentKeyProvider -PostQuantum.FileEncryption.Gcp.GcpKmsContentKeyProvider.GcpKmsContentKeyProvider(Google.Cloud.Kms.V1.KeyManagementServiceClient! kms, string! cryptoKeyName, System.ReadOnlyMemory additionalAuthenticatedData = default(System.ReadOnlyMemory)) -> void -PostQuantum.FileEncryption.Gcp.GcpKmsContentKeyProvider.ProviderId.get -> string! -PostQuantum.FileEncryption.Gcp.GcpKmsContentKeyProvider.UnwrapKeyAsync(System.ReadOnlyMemory wrapInfo, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! -PostQuantum.FileEncryption.Gcp.GcpKmsContentKeyProvider.WrapNewKeyAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<(byte[]! contentKey, byte[]! wrapInfo)>! diff --git a/src/PostQuantum.FileEncryption.Hybrid/PostQuantum.FileEncryption.Hybrid.csproj b/src/PostQuantum.FileEncryption.Hybrid/PostQuantum.FileEncryption.Hybrid.csproj index b71ac73..b8dd8ca 100644 --- a/src/PostQuantum.FileEncryption.Hybrid/PostQuantum.FileEncryption.Hybrid.csproj +++ b/src/PostQuantum.FileEncryption.Hybrid/PostQuantum.FileEncryption.Hybrid.csproj @@ -10,9 +10,9 @@ PostQuantum.FileEncryption.Hybrid - 1.5.0 - 1.5.0.0 - 1.5.0 + 1.6.0 + 1.6.0.0 + 1.6.0 6.0.0 PostQuantum.FileEncryption.Hybrid Open-source (MIT) post-quantum hybrid public-key encryption for PostQuantum.FileEncryption, for .NET 8 and .NET 10. Adds X25519 + ML-KEM-768 (FIPS 203) hybrid recipient encryption and multi-recipient support on top of the FROZEN, publicly specified .pqfe v2 container format — a file's content key stays safe if either primitive is later broken. Fully managed via BouncyCastle for both primitives (no platform ML-KEM dependency); runs anywhere .NET 8 or later does, including Linux, Windows, macOS, and constrained environments. Constant-memory streaming AES-256-GCM data plane via the core package handles files of any size. Public API surface locked by Microsoft.CodeAnalysis.PublicApiAnalyzers; CycloneDX SBOM and SLSA-style build-provenance attestation on every release. Recommended path for new code; supersedes the deprecated inline ML-KEM-only recipient mode (PQFE002) in the core package. diff --git a/src/PostQuantum.FileEncryption.Hybrid/README.md b/src/PostQuantum.FileEncryption.Hybrid/README.md index 2311b31..447e1d1 100644 --- a/src/PostQuantum.FileEncryption.Hybrid/README.md +++ b/src/PostQuantum.FileEncryption.Hybrid/README.md @@ -9,7 +9,7 @@ Fully managed (BouncyCastle) — **no native ML-KEM / OpenSSL 3.5 requirement**, .NET 8 or later runs (`net8.0` and `net10.0` targets). Produces standard `.pqfe` containers. ```bash -dotnet add package PostQuantum.FileEncryption.Hybrid --version 1.5.0 +dotnet add package PostQuantum.FileEncryption.Hybrid --version 1.6.0 ``` > **Versioning.** This package is intentionally kept in **lockstep** with diff --git a/src/PostQuantum.FileEncryption.Signing/PostQuantum.FileEncryption.Signing.csproj b/src/PostQuantum.FileEncryption.Signing/PostQuantum.FileEncryption.Signing.csproj index 992f882..eef6e2e 100644 --- a/src/PostQuantum.FileEncryption.Signing/PostQuantum.FileEncryption.Signing.csproj +++ b/src/PostQuantum.FileEncryption.Signing/PostQuantum.FileEncryption.Signing.csproj @@ -14,9 +14,9 @@ PostQuantum.FileEncryption.Signing - 1.5.0 - 1.5.0.0 - 1.5.0 + 1.6.0 + 1.6.0.0 + 1.6.0 6.0.0 PostQuantum.FileEncryption.Signing Open-source (MIT) post-quantum hybrid detached signatures for PostQuantum.FileEncryption, for .NET 8 and .NET 10. Signs any file or stream — typically a .pqfe container — with Ed25519 + ML-DSA-65 (FIPS 204) together and writes a small detached .sig sidecar, so a signature stays unforgeable if either primitive is later broken. Verification is fail-closed: both signatures must verify or PqSignatureException is thrown, with no oracle distinguishing why. Constant-memory streaming via SHA-512 pre-hash handles files of any size. The sidecar format is versioned and publicly specified (docs/SIGNATURE-FORMAT.md). Fully managed via BouncyCastle (no platform ML-DSA dependency); runs anywhere .NET 8 or later does. Adds sender authenticity on top of the encryption packages: AES-GCM proves a container was not altered, a detached signature proves who produced it. Public API surface locked by Microsoft.CodeAnalysis.PublicApiAnalyzers; CycloneDX SBOM and SLSA-style build-provenance attestation on every release. diff --git a/src/PostQuantum.FileEncryption.Signing/README.md b/src/PostQuantum.FileEncryption.Signing/README.md index f08e2f6..128220b 100644 --- a/src/PostQuantum.FileEncryption.Signing/README.md +++ b/src/PostQuantum.FileEncryption.Signing/README.md @@ -10,7 +10,7 @@ targets). The content is pre-hashed with streaming SHA-512, so signing a 10 GB b constant memory. ```bash -dotnet add package PostQuantum.FileEncryption.Signing --version 1.5.0 +dotnet add package PostQuantum.FileEncryption.Signing --version 1.6.0 ``` ## Sign and verify a file diff --git a/src/PostQuantum.FileEncryption/PostQuantum.FileEncryption.csproj b/src/PostQuantum.FileEncryption/PostQuantum.FileEncryption.csproj index 9bcae11..3642863 100644 --- a/src/PostQuantum.FileEncryption/PostQuantum.FileEncryption.csproj +++ b/src/PostQuantum.FileEncryption/PostQuantum.FileEncryption.csproj @@ -17,9 +17,9 @@ PostQuantum.FileEncryption - 1.5.0 - 1.5.0.0 - 1.5.0 + 1.6.0 + 1.6.0.0 + 1.6.0 6.0.0 PostQuantum.FileEncryption Open-source (MIT), fail-closed file and stream encryption for .NET 8 and .NET 10. Constant-memory chunked streaming encrypts files of any size — multi-gigabyte backups and media run in roughly 130 KB of working memory — stream-to-stream or file-to-file, with authenticated AES-256-GCM, progress reporting, cancellation, atomic file output, and zeroable passphrases. Key derivation is PBKDF2-HMAC-SHA256 (OWASP-default 600,000 iterations) or Argon2id, with decrypt-time cost ceilings for untrusted input. The .pqfe v2 container format is FROZEN for the 1.x line and publicly specified — pinned by cross-implementation test vectors against a byte-compatible Rust/WASM reference, so containers are portable across platforms and implementations. AES-256 keeps data confidential against a harvest-now-decrypt-later quantum adversary; production post-quantum public-key encryption ships as the companion package PostQuantum.FileEncryption.Hybrid (X25519 + ML-KEM-768 combiner with multi-recipient support). The IContentKeyProvider envelope-key seam wraps content keys with an external KMS/HSM so the master key never enters your process. Non-sensitive EventSource telemetry for SIEM/OpenTelemetry. AOT-compatible; deterministic builds; SourceLink; CycloneDX SBOM and SLSA-style build-provenance attestation on every release; public API surface locked by Microsoft.CodeAnalysis.PublicApiAnalyzers. From b17722f57590275e177175dc745a51af3a9e53fa Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Fri, 10 Jul 2026 20:08:43 -0400 Subject: [PATCH 7/7] fix(ci): pass --force in the interop harness loop The new CLI overwrite guard (refuses to replace an existing output without --force) broke the cross-implementation harness, which re-encrypts to the same temp paths every loop iteration. Add --force to the two reused .NET CLI calls; the other CI encrypt/decrypt calls write once to a fresh temp dir and are unaffected. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72d57a6..4cf93f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,12 +184,14 @@ jobs: 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"