From 311e378f9d97c1ee11c03ba9f75609f89a9e70a4 Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Tue, 30 Jun 2026 20:30:53 -0400 Subject: [PATCH 1/3] Prepare 1.0.0: hardened envelope, real rotation, locked API surface Bring the library to a stable 1.0 release. Crypto/security: - Hybrid envelope format v2: fold the ML-KEM encapsulation block into the AES-GCM associated data (HPKE-style; no unauthenticated bytes). Reads legacy v1 hybrid envelopes; AES envelopes unchanged (still v1). - Fail-fast validation of the default scheme at protector construction, so a misconfiguration (e.g. ML-KEM default on an unsupported host) throws at startup instead of on first write. - Pin the patched SQLite native bundle (SQLitePCLRaw 3.x) to clear advisory GHSA-2m69-gcr7-jv3q (test/sample-only; not shipped). API/DX: - IsEncrypted rejects unsupported property types with a clear, property-named error instead of an opaque EF model-build failure. - In-place, thread-safe key rotation on the in-memory rings (AddKey/ SetActiveKey/RemoveKey). Required because EF caches the model (and the captured protector) per context type, so swapping the ring cannot rotate. - Re-encryption helpers ReEncryptAsync() and MarkEncryptedPropertiesModified, which force EF to re-run the value converter (a plain SaveChanges does not, since change tracking compares the unchanged decrypted value). - Lock the public API surface with Microsoft.CodeAnalysis.PublicApiAnalyzers and a PublicAPI.Shipped.txt baseline. Sample: - Fix a latent bug where the KEK was disposed inside the configure lambda, breaking the sample on any ML-KEM-capable machine. Docs/versioning: - README: Always Encrypted/TDE comparison table, "not ASP.NET Data Protection" note, rewritten rotation guide, 1.x API + format stability commitment. - Update KNOWN-GAPS, threat model, migration guide, CHANGELOG; bump to 1.0.0. Tests expanded from 35 to 55 (envelope hardening + v1 back-compat, fail-fast, type guard, rotation, re-encryption, concurrency, documented relocation). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 71 +++++-- Directory.Packages.props | 14 ++ KNOWN-GAPS.md | 34 ++-- README.md | 77 +++++-- docs/migration.md | 42 +++- docs/threat-model.md | 16 +- samples/ClinicRecords/Program.cs | 12 +- .../Crypto/Aes256GcmSchemeHandler.cs | 9 + .../Crypto/EncryptedEnvelope.cs | 46 ++++- .../Crypto/IEncryptionSchemeHandler.cs | 10 + .../Crypto/MLKemEnvelopeSchemeHandler.cs | 71 ++++++- .../EncryptedDataMaintenance.cs | 185 +++++++++++++++++ .../EncryptedPropertyBuilderExtensions.cs | 13 ++ .../IPostQuantumProtector.cs | 7 +- .../Keys/InMemoryDataProtectionKeyRing.cs | 75 ++++++- .../Keys/InMemoryKeyEncapsulationKeyRing.cs | 66 +++++- .../PostQuantum.EntityFrameworkCore.csproj | 11 +- .../PublicAPI.Shipped.txt | 96 +++++++++ .../PublicAPI.Unshipped.txt | 1 + .../EnvelopeHardeningTests.cs | 94 +++++++++ .../FailFastValidationTests.cs | 89 +++++++++ .../IsEncryptedGuardTests.cs | 50 +++++ .../KeyRingRotationTests.cs | 66 ++++++ .../ProtectorBehaviorTests.cs | 67 +++++++ .../ReEncryptionTests.cs | 188 ++++++++++++++++++ 25 files changed, 1331 insertions(+), 79 deletions(-) create mode 100644 src/PostQuantum.EntityFrameworkCore/EntityFrameworkCore/EncryptedDataMaintenance.cs create mode 100644 src/PostQuantum.EntityFrameworkCore/PublicAPI.Shipped.txt create mode 100644 src/PostQuantum.EntityFrameworkCore/PublicAPI.Unshipped.txt create mode 100644 tests/PostQuantum.EntityFrameworkCore.Tests/EnvelopeHardeningTests.cs create mode 100644 tests/PostQuantum.EntityFrameworkCore.Tests/FailFastValidationTests.cs create mode 100644 tests/PostQuantum.EntityFrameworkCore.Tests/IsEncryptedGuardTests.cs create mode 100644 tests/PostQuantum.EntityFrameworkCore.Tests/KeyRingRotationTests.cs create mode 100644 tests/PostQuantum.EntityFrameworkCore.Tests/ProtectorBehaviorTests.cs create mode 100644 tests/PostQuantum.EntityFrameworkCore.Tests/ReEncryptionTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ed5fd2..4892a47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,23 +6,65 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +## [1.0.0] — 2026-06-30 + +First stable release. Commits to Semantic Versioning for the 1.x line: the public API and the +envelope format (PQE1, scheme ids, format versions 1–2) are stable. Data written by 1.x stays +readable across 1.x. + +### Added + +- **In-place key rotation on the in-memory rings.** `InMemoryDataProtectionKeyRing` and + `InMemoryKeyEncapsulationKeyRing` now expose thread-safe `AddKey`, `SetActiveKey`, and + `RemoveKey`, so rotation works on the ring the protector already holds. (Rebuilding a fresh + protector to rotate does not work — EF Core caches the model, including the captured + protector — so this is the supported path; a KMS-backed ring reflects its active key + dynamically.) +- **Re-encryption helpers** (`EncryptedDataMaintenance`): `DbContext.ReEncryptAsync()` + sweeps an entity's rows in batches and rewrites each encrypted column under the active + key/scheme; `DbContext.MarkEncryptedPropertiesModified(entity)` does the same for a custom + query. These force EF Core to re-run the value converter — a plain load-and-`SaveChanges` + does not, because change tracking compares the unchanged decrypted value. +- **Fail-fast startup validation.** Constructing the protector now verifies that the default + scheme is usable on this platform and has an active key, so a misconfiguration (for example + ML-KEM as the default on a host without it) throws at construction/startup rather than on the + first write. +- **Tracked public API surface.** A `PublicAPI.txt` baseline enforced by + `Microsoft.CodeAnalysis.PublicApiAnalyzers` makes any change to the public surface a + deliberate, reviewed edit. + +### Changed + +- **Hybrid envelope now authenticates the full encapsulation (format version 2).** The ML-KEM + scheme folds the KEM block (length + ciphertext) into the AES-GCM associated data — an + HPKE-style construction with no unauthenticated bytes in the body. Version-1 hybrid envelopes + written by 0.1.0 are still read. The AES-256-GCM scheme is unchanged and continues to emit + version-1 envelopes. **Compatibility:** 0.1.0 cannot read format-v2 hybrid envelopes, so + upgrade all nodes before writing post-quantum values. +- **`IsEncrypted` rejects unsupported property types** with a clear, property-named error + instead of an opaque EF Core model-build failure (use `string` or `byte[]`). + ### Fixed -- **Hybrid envelope: fail closed on a tampered KEM-ciphertext length.** The 2-byte KEM - ciphertext length lives in the envelope body, outside the AEAD associated data. A corrupted - length marker could hand real ML-KEM a wrong-sized ciphertext, which threw a raw +- **Hybrid envelope: fail closed on a tampered KEM-ciphertext length.** A corrupted length + marker could hand real ML-KEM a wrong-sized ciphertext, throwing a raw `ArgumentException`/`CryptographicException` out of `Decapsulate` instead of the library's - `PostQuantumCryptographicException` — an unhandled exception on the query path. The hybrid - handler now wraps that into `PostQuantumCryptographicException`, upholding the documented - "single generic exception" contract. + `PostQuantumCryptographicException`. The hybrid handler now wraps that, upholding the + "single generic exception" contract. (In format v2 the length is also authenticated.) + +### Security + +- **Supply chain:** pinned the SQLite native bundle used by tests and the sample to a patched + release (SQLitePCLRaw 3.x), clearing advisory GHSA-2m69-gcr7-jv3q. These are test/sample-only + dependencies and are not part of the shipped library package. ### Documentation -- Documented that the associated data binds version/scheme/key id but **not** the table, - column, or row, so an attacker with database write access can relocate a whole valid - envelope to another location sharing the same key id and it will decrypt. Recorded the - entity/property-binding and KEM-block-binding hardenings (gated on a format-version bump) in - the threat model and KNOWN-GAPS. +- Added a "this library vs. Always Encrypted / TDE" comparison table and an explicit note that + the library is **not** ASP.NET Core Data Protection. +- Expanded the key-rotation / re-encryption guide and documented the EF model-cache rotation + gotcha. Recorded that location binding (entity/property/row) remains out of scope and cannot + be complete at the value-converter layer. ## [0.1.0] — 2026-06-03 @@ -66,12 +108,13 @@ Initial release. Production-usable for encrypting sensitive EF Core columns at r ## What would come next Kept intentionally short and honest — these strengthen the library but are not required for -the v0.1 scenarios: +the 1.0 scenarios: - Optional `[Encrypted]` attribute / convention to complement the fluent API. - A nonce-budget guard that warns before a data key approaches its safe message limit. - Additional KEM parameter sets (ML-KEM-512/1024) behind the existing mechanism seam. -- A first-class PostQuantum.KeyManagement adapter package and a re-encryption sweep helper. +- A first-class PostQuantum.KeyManagement adapter package. -[Unreleased]: https://github.com/systemslibrarian/postquantum-entityframeworkcore/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/systemslibrarian/postquantum-entityframeworkcore/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/systemslibrarian/postquantum-entityframeworkcore/compare/v0.1.0...v1.0.0 [0.1.0]: https://github.com/systemslibrarian/postquantum-entityframeworkcore/releases/tag/v0.1.0 diff --git a/Directory.Packages.props b/Directory.Packages.props index f961ca8..2fcdc28 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,7 @@ + @@ -49,4 +50,17 @@ + + + + + + + + diff --git a/KNOWN-GAPS.md b/KNOWN-GAPS.md index 236b0b1..956ba0a 100644 --- a/KNOWN-GAPS.md +++ b/KNOWN-GAPS.md @@ -23,18 +23,20 @@ production. None of these are secret; several are intentional design choices. - **One KEM (ML-KEM-768).** ML-KEM-512/1024 and other KEMs are not wired up. The `IKeyEncapsulationMechanism` seam exists so they *can* be added without a format change. - **Associated data does not bind a value to its database location.** The AES-GCM associated - data is the envelope header (version/scheme/key id) only. It does **not** include the table, - column, or primary key, so an attacker with database *write* access can copy a whole valid - envelope from one row/column into another that shares the same key id and it will decrypt - (see the threat model's *Ciphertext relocation/replay* row). Binding the entity/property - into the associated data is a planned enhancement gated on a format-version bump. -- **The hybrid envelope's KEM-ciphertext block is not folded into the DEM associated data.** - The 2-byte KEM-ciphertext length and the KEM ciphertext sit in the body but outside the - AEAD's associated data. Tampering with them cannot leak plaintext — it yields a wrong - derived key and the AES-GCM tag check fails closed (a malformed length is now reported as a - `PostQuantumCryptographicException` rather than a raw exception). Absorbing the encapsulation - block into the associated data, as a strict HPKE construction would, is a defense-in-depth - hardening deferred to the same format-version bump above. + data is the envelope header (version/scheme/key id) — plus, in the hybrid scheme, the KEM + encapsulation block. It does **not** include the table, column, or primary key, so an + attacker with database *write* access can copy a whole valid envelope from one row/column + into another that shares the same key id and it will decrypt (see the threat model's + *Ciphertext relocation/replay* row). Binding the entity/property into the associated data is + a candidate enhancement gated on a future format-version bump — but note it cannot be + complete at the EF value-converter layer, which never sees a row's primary key, so + same-column row-to-row relocation would remain undefended even then. +- **The hybrid envelope now authenticates the full encapsulation (format v2).** As of 1.0 the + KEM-ciphertext length and ciphertext are folded into the AES-GCM associated data, so the + whole encapsulation is authenticated (an HPKE-style construction). Version-1 hybrid envelopes + written by 0.1.0 — which authenticated only the header, and already failed closed on a + tampered encapsulation because it produced a wrong derived key — are still read. The + AES-256-GCM scheme is unchanged and continues to emit version-1 envelopes. ## Platform support @@ -54,8 +56,12 @@ production. None of these are secret; several are intentional design choices. key-management layer (PostQuantum.KeyManagement / HSM / KMS) implementing the ring interfaces. - **No automatic rotation or re-encryption job.** Rotation is *safe* (old values stay - readable by key id), but the library does not *schedule* rotation or sweep old rows. You - drive that from your application or key-management layer. + readable by key id) and *supported* — rotate the active key in place with the ring's + `AddKey`/`SetActiveKey`, and re-encrypt existing rows with `DbContext.ReEncryptAsync()` + (or `MarkEncryptedPropertiesModified` for custom sweeps) — but the library does not + *schedule* rotation. You decide when to rotate and when to run the sweep. Note that rebuilding + a fresh protector/ring to rotate does **not** work: EF Core caches the model (and the + captured protector) per context type, so you must mutate the ring the protector already holds. - **In-memory keys are process-lifetime.** `InMemoryDataProtectionKeyRing` and `InMemoryKeyEncapsulationKeyRing` hold material in managed memory (zeroed on dispose). They are for development, tests, and small self-hosted use — not a substitute for an HSM. diff --git a/README.md b/README.md index 63d858c..9e15e3d 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,29 @@ clean seam for real key management. encryption (and consider using this *on top* for the few crown-jewel columns). - You need **format-preserving** encryption (e.g. keep a 16-digit number 16 digits). +### This library vs. full-database solutions + +| | **This library** (field encryption) | **Always Encrypted** (SQL Server) | **TDE** / filesystem encryption | +| --- | --- | --- | --- | +| **Granularity** | Per chosen column | Per chosen column | Whole database / disk | +| **Threat covered** | DB dump, backup, storage read access | DB admin + storage; keys stay client-side | DB files / disk at rest | +| **Protects against a live, compromised DB connection** | Yes (server only ever sees ciphertext) | Yes | **No** (data is decrypted for any valid connection) | +| **Queryable encrypted columns** | No (non-deterministic) | Limited (deterministic columns only) | Yes (transparent) | +| **Post-quantum key wrapping** | **Yes** (ML-KEM-768 option) | No | No | +| **Database engine support** | Any EF Core provider | SQL Server / Azure SQL | Engine-specific | +| **Key custody** | Your KMS/HSM via key rings | Windows cert store / Azure Key Vault | Engine / OS | + +**Rule of thumb:** use **TDE/filesystem encryption** for blanket at-rest protection of the +whole database, and add **this library on top** for the few crown-jewel columns you want +protected even from someone who can read the live database — with a post-quantum migration +path for the key-wrapping layer. Reach for **Always Encrypted** instead if you are all-in on +SQL Server and need limited equality queries on protected columns. + +> **Not** ASP.NET Core Data Protection. Despite the familiar `Protect`/`Unprotect` shape and +> the `IDataProtectionKeyRing` name, this library does not use or extend +> `Microsoft.AspNetCore.DataProtection`. That stack is not post-quantum and has a different +> key-lifetime and rotation model; this library is a separate, purpose-built at-rest cipher. + ## Quick start Install (from this repository or, once published, from NuGet): @@ -151,7 +174,10 @@ PQE1 | ver | scheme | keyIdLen | keyId | scheme-specific body Because the whole header is fed to AES-GCM as associated data, the format version, the scheme, and the key id are all cryptographically bound to the ciphertext. There is **no -silent downgrade** and **no key-id confusion**. +silent downgrade** and **no key-id confusion**. The hybrid scheme additionally folds its KEM +encapsulation block into the associated data (envelope format version 2), so the entire +encapsulation is authenticated — an HPKE-style construction. The `PQE1` magic is a fixed +family marker; the version *byte* governs the layout, and readers accept versions 1 and 2. | Scheme | Id | What it does | Post-quantum? | | --- | --- | --- | --- | @@ -191,14 +217,32 @@ its own dependencies, lifetime, and disposal. ## Key rotation -Rotation is first-class because the key id travels inside every envelope: +Rotation is first-class because the key id travels inside every envelope. Rotate **in place** +on the ring the protector already holds: -1. Add a new key to the ring and mark it active. New writes use it automatically. -2. Keep old keys in the ring. Existing rows still decrypt by their recorded key id. -3. Optionally re-encrypt old rows in the background (load → `SaveChanges`) to retire a key. +```csharp +dekRing.AddKey(DataEncryptionKey.Generate("dek-2026-07")); // add the new key +dekRing.SetActiveKey("dek-2026-07"); // new writes use it; old rows still decrypt +int rewritten = await db.ReEncryptAsync(); // re-encrypt existing rows under the new key +dekRing.RemoveKey("dek-2026-01"); // retire the old key once the sweep is done +``` + +1. Add a new key and activate it. New writes use it automatically; existing rows still decrypt + by their recorded key id. +2. Re-encrypt old rows with `ReEncryptAsync()` (or `MarkEncryptedPropertiesModified` for a + custom query) to retire a key. A plain load-and-`SaveChanges` will **not** rewrite an + unchanged value — change tracking compares the decrypted value — so the helper marks the + columns for you. +3. Remove the old key from the ring. + +> **Rotate in place, not by swapping the protector.** EF Core caches the model, and the value +> converters in that cached model capture the protector instance. Build a *new* protector/ring +> and nothing changes until the cache is invalidated — so mutate the ring the protector holds +> (the in-memory rings are thread-safe; a KMS-backed ring reflects its active key dynamically). The same applies to schemes: register both the AES and ML-KEM handlers during a migration -and old AES rows keep decrypting while new rows use the post-quantum envelope. +and old AES rows keep decrypting while new rows use the post-quantum envelope. See +[docs/migration.md](docs/migration.md) for the full rotation and backfill guide. ## Threat model @@ -237,8 +281,10 @@ itemized list of current limitations. - **Encrypted columns are not queryable** in the database (no `WHERE`, index, sort, or join on the protected value). This is intentional — encryption is non-deterministic. -- **No automatic key rotation/scheduling.** The library makes rotation *safe*; it does not - *drive* it. That belongs in PostQuantum.KeyManagement. +- **No automatic key rotation/scheduling.** The library makes rotation *safe* and provides + helpers to perform it (`AddKey`/`SetActiveKey`/`RemoveKey` on the ring and + `ReEncryptAsync()`), but it does not *schedule* it — you decide when. Scheduling belongs + in PostQuantum.KeyManagement. - **ML-KEM availability is platform-dependent** (see below). Where unavailable, you get a clear `PlatformNotSupportedException` rather than a silent downgrade. AES-256-GCM always works. @@ -308,10 +354,17 @@ dotnet run --project samples/ClinicRecords ## Versioning & roadmap -This is **v0.1.0** and is intended to be production-usable today for the scenarios above: -the cryptography is standard and BCL-backed, the envelope is versioned and authenticated, -and key rotation and scheme migration work. See [CHANGELOG.md](CHANGELOG.md) for the precise -contents of this release and a short, honest note on what would come next. +This is **v1.0.0**. It follows [Semantic Versioning](https://semver.org); for the 1.x line: + +- **API stability.** The public surface is tracked (a `PublicAPI.txt` baseline enforced by an + analyzer). No breaking changes to public types within 1.x. +- **Format stability.** The `PQE1` envelope, the scheme ids (`Aes256Gcm = 1`, + `MLKem768Aes256Gcm = 2`), and envelope format versions 1 and 2 are frozen for 1.x. Any new + format is introduced under a new version byte that 1.x can still read; data written by 1.x + stays readable across 1.x. (Note: 0.1.0 cannot read the hybrid format-v2 envelopes 1.0 + writes — upgrade all nodes before writing post-quantum values.) + +See [CHANGELOG.md](CHANGELOG.md) for the precise contents of this release. ## License diff --git a/docs/migration.md b/docs/migration.md index fba7f05..bcb1d4e 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -58,14 +58,46 @@ services.AddPostQuantumEncryption(pq => ## Rotating a data-encryption key +Rotate **in place** on the ring your protector already holds. Do *not* build a new protector +or ring to rotate: EF Core caches the model, and the value converters in that cached model +capture the protector instance, so a swapped protector has no effect until the model cache is +invalidated. The in-memory rings expose thread-safe rotation for exactly this reason (a +production KMS-backed ring instead reflects the active key dynamically). + +```csharp +// dekRing is the same IDataProtectionKeyRing instance the protector was built with. +dekRing.AddKey(DataEncryptionKey.Generate("dek-2026-07")); // new writes still use the old key… +dekRing.SetActiveKey("dek-2026-07"); // …until you activate the new one +``` + +New writes now use `dek-2026-07`; rows written under `dek-2026-01` still decrypt because the +old key remains in the ring. + +### Re-encrypting existing rows to retire the old key + +A plain load-and-`SaveChanges` does **not** rewrite an unchanged value: EF Core change +tracking compares the *decrypted* model value, which is unchanged by rotation, so no UPDATE is +generated. Use the helpers, which mark the encrypted columns so EF re-runs the converter: + +```csharp +// Sweep every row of an entity in batches, rewriting each encrypted column under the +// now-active key. Safe to run online. +int rewritten = await db.ReEncryptAsync(batchSize: 500); + +// …or, for a custom query / composite keys, force re-encryption per entity: +foreach (var c in db.Customers.Where(/* your filter */)) + db.MarkEncryptedPropertiesModified(c); +await db.SaveChangesAsync(); +``` + +Once every row is re-encrypted, retire the old key: + ```csharp -var ring = new InMemoryDataProtectionKeyRing( - activeKeyId: "dek-2026-07", - keys: [oldKey /* dek-2026-01 */, newKey /* dek-2026-07 */]); +dekRing.RemoveKey("dek-2026-01"); // throws if it is still the active key ``` -New writes use `dek-2026-07`; rows written under `dek-2026-01` still decrypt. Re-encrypt in -the background to retire the old key, then drop it from the ring. +The same `AddKey`/`SetActiveKey`/`RemoveKey` surface exists on +`InMemoryKeyEncapsulationKeyRing` for rotating ML-KEM key-encapsulation keys. ## Choosing a column type diff --git a/docs/threat-model.md b/docs/threat-model.md index 63cdc79..fb76ec3 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -39,8 +39,16 @@ before relying on the library for anything that matters. header does **not** include the table, column, or primary key, so the associated data does not bind a value to its database location (see *Ciphertext relocation/replay* above). Binding to a logical destination — e.g. mixing the entity and property name into the associated data — - is a planned enhancement that would require a format-version bump; track it in - [KNOWN-GAPS.md](../KNOWN-GAPS.md). + remains a candidate enhancement that would require a future format-version bump; track it in + [KNOWN-GAPS.md](../KNOWN-GAPS.md). Note that this binding cannot be complete at the EF + value-converter layer: a converter never sees the row's primary key, so same-column, + row-to-row relocation cannot be defeated here even with entity/property binding. +- **Whole-encapsulation authentication (hybrid, format v2).** In the ML-KEM hybrid scheme the + KEM encapsulation block (its length and ciphertext) is folded into the AES-GCM associated + data, so the entire encapsulation is authenticated — an HPKE-style construction with no + unauthenticated bytes in the body. Version-1 hybrid envelopes written by 0.1.0 (which + authenticated only the header, and already failed closed on a tampered encapsulation via a + wrong derived key) are still read. - **Per-value randomness.** A fresh 96-bit nonce per value (and a fresh KEM encapsulation per value in the hybrid scheme) prevents equality correlation and nonce reuse within a key. - **HKDF domain separation.** The hybrid scheme derives the data key with HKDF-SHA256 using @@ -54,7 +62,9 @@ before relying on the library for anything that matters. ## Operational guidance -- Keep keys in a managed store; rotate DEKs on a schedule. +- Keep keys in a managed store; rotate DEKs on a schedule. Rotate the active key in place on + the ring the protector holds, then retire the old key once a re-encryption sweep completes + (see [migration.md](migration.md) and `DbContext.ReEncryptAsync()`). - Prefer the ML-KEM hybrid scheme for new data on supported platforms. - Treat decrypted values as toxic: minimize where they live and how long. - Pad values whose *length* is sensitive before storing them. diff --git a/samples/ClinicRecords/Program.cs b/samples/ClinicRecords/Program.cs index 7e63205..1536c7c 100644 --- a/samples/ClinicRecords/Program.cs +++ b/samples/ClinicRecords/Program.cs @@ -24,6 +24,14 @@ var mlkem = new MLKemKeyEncapsulationMechanism(); bool postQuantum = mlkem.IsSupported; +// The KEK ring must outlive configuration: the value converters in EF Core's cached +// model hold the protector, which holds this ring, for the whole program. (Declaring the +// key pair inside the configure lambda below would dispose it as soon as the lambda +// returns, leaving the ring holding a disposed key.) Disposed at program exit. +using var kekRing = postQuantum + ? new InMemoryKeyEncapsulationKeyRing(mlkem.GenerateKeyPair("kek-sample-2026-01")) + : null; + // --------------------------------------------------------------------------- // 2. Configure encryption. Prefer the post-quantum hybrid envelope when the // platform supports ML-KEM; always keep AES-256-GCM available. @@ -33,11 +41,9 @@ { if (postQuantum) { - using var kek = mlkem.GenerateKeyPair("kek-sample-2026-01"); - var kekRing = new InMemoryKeyEncapsulationKeyRing(kek); pq.UseKeyEncapsulationMechanism(mlkem); pq.UseAes256Gcm(dekRing, asDefault: false); // kept for legacy/interop - pq.UseMLKem768Envelope(kekRing); // default for new writes + pq.UseMLKem768Envelope(kekRing!); // default for new writes } else { diff --git a/src/PostQuantum.EntityFrameworkCore/Crypto/Aes256GcmSchemeHandler.cs b/src/PostQuantum.EntityFrameworkCore/Crypto/Aes256GcmSchemeHandler.cs index 4f01836..f10e961 100644 --- a/src/PostQuantum.EntityFrameworkCore/Crypto/Aes256GcmSchemeHandler.cs +++ b/src/PostQuantum.EntityFrameworkCore/Crypto/Aes256GcmSchemeHandler.cs @@ -17,6 +17,15 @@ internal Aes256GcmSchemeHandler(IDataProtectionKeyRing keyRing) public EncryptionScheme Scheme => EncryptionScheme.Aes256Gcm; + public void ValidateReady() + { + // Resolving the active key proves the ring is wired up and has a usable DEK. + DataEncryptionKey active = _keyRing.ActiveKey + ?? throw new PostQuantumCryptographicException( + "The data-protection key ring returned no active key for the AES-256-GCM scheme."); + _ = active.KeyId; + } + public byte[] Encrypt(ReadOnlySpan plaintext) { DataEncryptionKey key = _keyRing.ActiveKey; diff --git a/src/PostQuantum.EntityFrameworkCore/Crypto/EncryptedEnvelope.cs b/src/PostQuantum.EntityFrameworkCore/Crypto/EncryptedEnvelope.cs index 28b5105..f5139f1 100644 --- a/src/PostQuantum.EntityFrameworkCore/Crypto/EncryptedEnvelope.cs +++ b/src/PostQuantum.EntityFrameworkCore/Crypto/EncryptedEnvelope.cs @@ -10,8 +10,8 @@ namespace PostQuantum.EntityFrameworkCore.Crypto; /// The fixed header is identical for every scheme: /// /// Offset Size Field -/// 0 4 Magic ("PQE1") -/// 4 1 Format version (currently 1) +/// 0 4 Magic ("PQE1" — a fixed family marker, not the format version) +/// 4 1 Format version (see remarks) /// 5 1 Scheme id (see EncryptionScheme) /// 6 2 Key id length, big-endian uint16 /// 8 L Key id (UTF-8) @@ -23,15 +23,37 @@ namespace PostQuantum.EntityFrameworkCore.Crypto; /// ciphertext. An attacker cannot downgrade the scheme, swap the key id, or strip the /// version without invalidating the authentication tag. /// +/// +/// Format versions. The 4-byte magic is a constant brand marker; the version +/// byte at offset 4 is authoritative. Version 1 is the original layout. +/// Version 2 is used only by the hybrid scheme and additionally folds the KEM +/// encapsulation block (its 2-byte length and ciphertext) into the AES-GCM associated +/// data, so the entire encapsulation is authenticated (an HPKE-style construction). The +/// AES-256-GCM scheme has no encapsulation block and continues to emit version 1, +/// so existing AES envelopes are bit-for-bit unchanged. Readers accept versions 1 and 2. +/// /// internal static class EncryptedEnvelope { /// ASCII "PQE1" — magic bytes that prefix every envelope. internal static readonly byte[] Magic = "PQE1"u8.ToArray(); - /// Current envelope format version. + /// + /// Original envelope format version. Emitted by schemes that have no key-encapsulation + /// block (currently AES-256-GCM) and read for all schemes for backward compatibility. + /// internal const byte FormatVersion = 1; + /// + /// Hybrid envelope format version: identical header layout to version 1, but the KEM + /// encapsulation block is additionally included in the AEAD associated data. Emitted by + /// the ML-KEM hybrid scheme; version-1 hybrid envelopes (written by 0.1.0) still decrypt. + /// + internal const byte HybridFormatVersion = 2; + + /// Byte offset of the format-version field within every envelope header. + internal const int VersionOffset = 4; + private const int MagicLength = 4; private const int MinHeaderLength = MagicLength + 1 + 1 + 2; // magic + version + scheme + keyIdLen @@ -43,7 +65,7 @@ internal static class EncryptedEnvelope /// both the literal prefix of the envelope and the associated data for authenticated /// encryption. /// - internal static byte[] WriteHeader(EncryptionScheme scheme, string keyId) + internal static byte[] WriteHeader(EncryptionScheme scheme, string keyId, byte version = FormatVersion) { ArgumentNullException.ThrowIfNull(keyId); int keyIdByteCount = Encoding.UTF8.GetByteCount(keyId); @@ -60,7 +82,7 @@ internal static byte[] WriteHeader(EncryptionScheme scheme, string keyId) var header = new byte[MinHeaderLength + keyIdByteCount]; Magic.CopyTo(header.AsSpan()); - header[4] = FormatVersion; + header[VersionOffset] = version; header[5] = (byte)scheme; BinaryPrimitives.WriteUInt16BigEndian(header.AsSpan(6, 2), (ushort)keyIdByteCount); Encoding.UTF8.GetBytes(keyId, header.AsSpan(MinHeaderLength)); @@ -87,11 +109,12 @@ internal static ParsedEnvelope Parse(ReadOnlyMemory payload) throw new PostQuantumCryptographicException("Encrypted payload is not a recognized PostQuantum envelope."); } - byte version = span[4]; - if (version != FormatVersion) + byte version = span[VersionOffset]; + if (version is not (FormatVersion or HybridFormatVersion)) { throw new PostQuantumCryptographicException( - $"Unsupported envelope format version {version}. This build understands version {FormatVersion}."); + $"Unsupported envelope format version {version}. This build understands versions " + + $"{FormatVersion} and {HybridFormatVersion}."); } var scheme = (EncryptionScheme)span[5]; @@ -112,7 +135,7 @@ internal static ParsedEnvelope Parse(ReadOnlyMemory payload) // The header bytes ARE the associated data used to authenticate the body. ReadOnlyMemory associatedData = payload[..headerLength]; ReadOnlyMemory body = payload[headerLength..]; - return new ParsedEnvelope(scheme, keyId, associatedData, body); + return new ParsedEnvelope(version, scheme, keyId, associatedData, body); } } @@ -120,17 +143,22 @@ internal static ParsedEnvelope Parse(ReadOnlyMemory payload) internal readonly struct ParsedEnvelope { internal ParsedEnvelope( + byte version, EncryptionScheme scheme, string keyId, ReadOnlyMemory associatedData, ReadOnlyMemory body) { + Version = version; Scheme = scheme; KeyId = keyId; AssociatedData = associatedData; Body = body; } + /// The format version declared by the envelope header (1 or 2). + internal byte Version { get; } + /// The scheme declared by the envelope header. internal EncryptionScheme Scheme { get; } diff --git a/src/PostQuantum.EntityFrameworkCore/Crypto/IEncryptionSchemeHandler.cs b/src/PostQuantum.EntityFrameworkCore/Crypto/IEncryptionSchemeHandler.cs index b784708..353c28a 100644 --- a/src/PostQuantum.EntityFrameworkCore/Crypto/IEncryptionSchemeHandler.cs +++ b/src/PostQuantum.EntityFrameworkCore/Crypto/IEncryptionSchemeHandler.cs @@ -10,6 +10,16 @@ internal interface IEncryptionSchemeHandler /// The scheme this handler implements. EncryptionScheme Scheme { get; } + /// + /// Verifies that this handler can encrypt new values right now: its platform support is + /// present and an active key is resolvable. Called for the default scheme when the + /// protector is constructed so that misconfiguration fails fast at startup rather than on + /// the first write. + /// + /// The scheme's platform support is absent. + /// No active key is available. + void ValidateReady(); + /// /// Produces a complete envelope (header + body) for using /// this scheme's active key. diff --git a/src/PostQuantum.EntityFrameworkCore/Crypto/MLKemEnvelopeSchemeHandler.cs b/src/PostQuantum.EntityFrameworkCore/Crypto/MLKemEnvelopeSchemeHandler.cs index dcf40cf..ff1d163 100644 --- a/src/PostQuantum.EntityFrameworkCore/Crypto/MLKemEnvelopeSchemeHandler.cs +++ b/src/PostQuantum.EntityFrameworkCore/Crypto/MLKemEnvelopeSchemeHandler.cs @@ -20,6 +20,13 @@ namespace PostQuantum.EntityFrameworkCore.Crypto; /// HKDF binds the derivation to the key id (as salt) and a fixed context string (as info), /// providing domain separation across schemes and keys. /// +/// +/// Format version 2 (current). The AES-GCM associated data is the envelope header +/// plus the KEM block (kemCtLen || kemCiphertext), so the entire encapsulation +/// is authenticated — an HPKE-style construction with no unauthenticated bytes in the body. +/// Version-1 hybrid envelopes written by 0.1.0 (which authenticated only the header) are +/// still read: decryption rebuilds the version-1 associated data when it sees version 1. +/// /// internal sealed class MLKemEnvelopeSchemeHandler : IEncryptionSchemeHandler { @@ -37,10 +44,28 @@ internal MLKemEnvelopeSchemeHandler(IKeyEncapsulationKeyRing keyRing, IKeyEncaps public EncryptionScheme Scheme => EncryptionScheme.MLKem768Aes256Gcm; + public void ValidateReady() + { + if (!_kem.IsSupported) + { + throw new PlatformNotSupportedException( + $"The {Scheme} scheme is configured as the default for new writes, but the " + + $"'{_kem.AlgorithmName}' mechanism is unavailable on this platform. ML-KEM requires " + + ".NET 10+ with OpenSSL 3.5+ (Linux/macOS) or a recent Windows CNG. Probe " + + "MLKemKeyEncapsulationMechanism.IsSupported at startup and fall back to UseAes256Gcm " + + "where it is false. See KNOWN-GAPS.md."); + } + + KeyEncapsulationKeyPair active = _keyRing.ActiveKey + ?? throw new PostQuantumCryptographicException( + "The key-encapsulation key ring returned no active key for the ML-KEM hybrid scheme."); + _ = active.KeyId; + } + public byte[] Encrypt(ReadOnlySpan plaintext) { KeyEncapsulationKeyPair publicKey = _keyRing.ActiveKey; - byte[] header = EncryptedEnvelope.WriteHeader(Scheme, publicKey.KeyId); + byte[] header = EncryptedEnvelope.WriteHeader(Scheme, publicKey.KeyId, EncryptedEnvelope.HybridFormatVersion); EncapsulationResult encapsulation = _kem.Encapsulate(publicKey); byte[] sharedSecret = encapsulation.SharedSecret; @@ -51,20 +76,25 @@ public byte[] Encrypt(ReadOnlySpan plaintext) throw new PostQuantumCryptographicException("KEM ciphertext is unexpectedly large."); } + // The KEM block (length + ciphertext) sits at the front of the body and is also + // folded into the AEAD associated data (format version 2), so the full encapsulation + // is authenticated alongside the header. + var kemBlock = new byte[2 + kemCiphertext.Length]; + BinaryPrimitives.WriteUInt16BigEndian(kemBlock.AsSpan(0, 2), (ushort)kemCiphertext.Length); + kemCiphertext.CopyTo(kemBlock.AsSpan(2)); + + byte[] associatedData = BuildAssociatedData(header, kemBlock); + Span dek = stackalloc byte[AuthenticatedCipher.KeySizeInBytes]; try { DeriveKey(sharedSecret, publicKey.KeyId, dek); - byte[] dem = AuthenticatedCipher.Encrypt(dek, plaintext, header); - - var body = new byte[2 + kemCiphertext.Length + dem.Length]; - BinaryPrimitives.WriteUInt16BigEndian(body.AsSpan(0, 2), (ushort)kemCiphertext.Length); - kemCiphertext.CopyTo(body.AsSpan(2)); - dem.CopyTo(body.AsSpan(2 + kemCiphertext.Length)); + byte[] dem = AuthenticatedCipher.Encrypt(dek, plaintext, associatedData); - var envelope = new byte[header.Length + body.Length]; + var envelope = new byte[header.Length + kemBlock.Length + dem.Length]; header.CopyTo(envelope.AsSpan()); - body.CopyTo(envelope.AsSpan(header.Length)); + kemBlock.CopyTo(envelope.AsSpan(header.Length)); + dem.CopyTo(envelope.AsSpan(header.Length + kemBlock.Length)); return envelope; } finally @@ -88,6 +118,7 @@ public byte[] Decrypt(string keyId, ReadOnlyMemory associatedData, ReadOnl throw new PostQuantumCryptographicException("Envelope body is truncated within the KEM ciphertext."); } + ReadOnlyMemory kemBlock = body.Slice(0, 2 + kemCtLength); ReadOnlyMemory kemCiphertext = body.Slice(2, kemCtLength); ReadOnlyMemory dem = body.Slice(2 + kemCtLength); @@ -118,11 +149,19 @@ public byte[] Decrypt(string keyId, ReadOnlyMemory associatedData, ReadOnl "tampering, corruption, or use of the wrong key.", ex); } + // Format version 2 folds the KEM block into the associated data; version 1 (written + // by 0.1.0) authenticated only the header. Rebuild the matching AAD for the version. + byte version = associatedData.Span[EncryptedEnvelope.VersionOffset]; + byte[]? aadBuffer = version >= EncryptedEnvelope.HybridFormatVersion + ? BuildAssociatedData(associatedData.Span, kemBlock.Span) + : null; + ReadOnlySpan aad = aadBuffer ?? associatedData.Span; + Span dek = stackalloc byte[AuthenticatedCipher.KeySizeInBytes]; try { DeriveKey(sharedSecret, keyId, dek); - return AuthenticatedCipher.Decrypt(dek, dem.Span, associatedData.Span); + return AuthenticatedCipher.Decrypt(dek, dem.Span, aad); } finally { @@ -131,6 +170,18 @@ public byte[] Decrypt(string keyId, ReadOnlyMemory associatedData, ReadOnl } } + /// + /// Concatenates the envelope header and the KEM block into the associated data used by + /// the version-2 hybrid construction, so the entire encapsulation is authenticated. + /// + private static byte[] BuildAssociatedData(ReadOnlySpan header, ReadOnlySpan kemBlock) + { + var associatedData = new byte[header.Length + kemBlock.Length]; + header.CopyTo(associatedData); + kemBlock.CopyTo(associatedData.AsSpan(header.Length)); + return associatedData; + } + private static void DeriveKey(ReadOnlySpan sharedSecret, string keyId, Span destination) { Span salt = stackalloc byte[EncryptedEnvelope.MaxKeyIdLength]; diff --git a/src/PostQuantum.EntityFrameworkCore/EntityFrameworkCore/EncryptedDataMaintenance.cs b/src/PostQuantum.EntityFrameworkCore/EntityFrameworkCore/EncryptedDataMaintenance.cs new file mode 100644 index 0000000..1835b8d --- /dev/null +++ b/src/PostQuantum.EntityFrameworkCore/EntityFrameworkCore/EncryptedDataMaintenance.cs @@ -0,0 +1,185 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata; + +namespace PostQuantum.EntityFrameworkCore.EntityFrameworkCore; + +/// +/// Helpers for re-encrypting existing rows after a key or scheme rotation. +/// +/// +/// +/// Re-encryption is the operation that retires an old key or scheme: every value is read, +/// then written again so it is stored under the active key/scheme recorded in a fresh +/// envelope. Once every row is re-encrypted, the old key can be dropped from the ring. +/// +/// +/// Why a helper is needed. EF Core change tracking compares the property's model +/// (decrypted) value, which is unchanged by rotation, so a plain load-and-SaveChanges +/// generates no UPDATE and the ciphertext is never rewritten. These helpers explicitly mark +/// the encrypted properties as modified, forcing EF to round-trip them through the value +/// converter and re-emit the envelope under the active key/scheme. +/// +/// +public static class EncryptedDataMaintenance +{ + /// + /// The member kinds EF Core needs preserved on an entity type for model and query use. + /// Mirrors the annotation on so trimming/AOT is honored. + /// + private const DynamicallyAccessedMemberTypes EntityMembers = + DynamicallyAccessedMemberTypes.PublicConstructors + | DynamicallyAccessedMemberTypes.NonPublicConstructors + | DynamicallyAccessedMemberTypes.PublicFields + | DynamicallyAccessedMemberTypes.NonPublicFields + | DynamicallyAccessedMemberTypes.PublicProperties + | DynamicallyAccessedMemberTypes.NonPublicProperties + | DynamicallyAccessedMemberTypes.Interfaces; + + /// + /// Marks every encrypted property of a tracked entity as modified so the next + /// re-encrypts it under the active key and scheme. + /// Non-encrypted properties are untouched; values stay null. + /// + /// The number of encrypted properties that were marked modified. + public static int MarkEncryptedPropertiesModified<[DynamicallyAccessedMembers(EntityMembers)] TEntity>( + this DbContext context, TEntity entity) + where TEntity : class + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(entity); + + Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry entry = context.Entry(entity); + int count = 0; + foreach (string name in GetEncryptedPropertyNames(context, typeof(TEntity))) + { + if (ForceReEncrypt(entry.Property(name))) + { + count++; + } + } + + return count; + } + + /// + /// Forces a single property to be re-encrypted on the next save. Marks it modified and + /// clears the tracked original value so that change detection — which compares the + /// decrypted model values and would otherwise reset the flag because the plaintext + /// is unchanged — keeps the property modified and re-runs the value converter. A property + /// whose current value is is left untouched (null stays null). + /// + /// if the property was marked for re-encryption. + private static bool ForceReEncrypt( + Microsoft.EntityFrameworkCore.ChangeTracking.PropertyEntry property) + { + if (property.CurrentValue is null) + { + return false; + } + + property.OriginalValue = null; + property.IsModified = true; + return true; + } + + /// + /// Re-encrypts every row of in batches, rewriting each + /// encrypted column under the active key and scheme. Safe to run while the application is + /// online; run it after rotating a key (and registering both old and new keys in the + /// ring), then drop the old key once this completes. + /// + /// The context whose model declares the encrypted properties. + /// Rows to load, re-encrypt, and save per batch. + /// A token to cancel the sweep between batches. + /// The total number of rows re-encrypted. + /// + /// Requires a single-column primary key for stable paging. For composite keys or custom + /// paging, iterate your own query and call + /// on each entity instead. + /// + public static async Task ReEncryptAsync<[DynamicallyAccessedMembers(EntityMembers)] TEntity>( + this DbContext context, + int batchSize = 500, + CancellationToken cancellationToken = default) + where TEntity : class + { + ArgumentNullException.ThrowIfNull(context); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(batchSize); + + IEntityType entityType = context.Model.FindEntityType(typeof(TEntity)) + ?? throw new ArgumentException( + $"'{typeof(TEntity)}' is not part of this context's model.", nameof(context)); + + string[] encrypted = [.. GetEncryptedPropertyNames(context, typeof(TEntity))]; + if (encrypted.Length == 0) + { + return 0; + } + + IKey primaryKey = entityType.FindPrimaryKey() + ?? throw new InvalidOperationException( + $"'{typeof(TEntity)}' has no primary key, which is required to page through rows. " + + "Re-encrypt manually using MarkEncryptedPropertiesModified."); + if (primaryKey.Properties.Count != 1) + { + throw new InvalidOperationException( + $"'{typeof(TEntity)}' has a composite primary key, which automatic paging does not " + + "support. Iterate your own ordered query and call MarkEncryptedPropertiesModified."); + } + + string keyName = primaryKey.Properties[0].Name; + int total = 0; + for (int skip = 0; ; skip += batchSize) + { + List batch = await context.Set() + .OrderBy(e => EF.Property(e, keyName)) + .Skip(skip) + .Take(batchSize) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (batch.Count == 0) + { + break; + } + + foreach (TEntity entity in batch) + { + Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry entry = context.Entry(entity); + foreach (string name in encrypted) + { + ForceReEncrypt(entry.Property(name)); + } + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + total += batch.Count; + + // Detach the batch so the change tracker does not grow across a large sweep. + foreach (TEntity entity in batch) + { + context.Entry(entity).State = EntityState.Detached; + } + } + + return total; + } + + private static IEnumerable GetEncryptedPropertyNames( + DbContext context, + [DynamicallyAccessedMembers(EntityMembers)] Type clrType) + { + IEntityType entityType = context.Model.FindEntityType(clrType) + ?? throw new ArgumentException( + $"'{clrType}' is not part of this context's model.", nameof(clrType)); + + foreach (IProperty property in entityType.GetProperties()) + { + if (property.GetValueConverter() is EncryptedStringConverter or EncryptedBinaryConverter) + { + yield return property.Name; + } + } + } +} diff --git a/src/PostQuantum.EntityFrameworkCore/EntityFrameworkCore/EncryptedPropertyBuilderExtensions.cs b/src/PostQuantum.EntityFrameworkCore/EntityFrameworkCore/EncryptedPropertyBuilderExtensions.cs index 6c6eed5..f195d59 100644 --- a/src/PostQuantum.EntityFrameworkCore/EntityFrameworkCore/EncryptedPropertyBuilderExtensions.cs +++ b/src/PostQuantum.EntityFrameworkCore/EntityFrameworkCore/EncryptedPropertyBuilderExtensions.cs @@ -37,6 +37,19 @@ public static PropertyBuilder IsEncrypted( { ArgumentNullException.ThrowIfNull(propertyBuilder); ArgumentNullException.ThrowIfNull(protector); + + Type clrType = propertyBuilder.Metadata.ClrType; + if (clrType != typeof(string)) + { + string name = propertyBuilder.Metadata.Name; + throw new ArgumentException( + $"IsEncrypted cannot be applied to property '{name}' of type '{clrType}'. This overload " + + "encrypts string properties; for binary data use a byte[] property (the byte[] overload is " + + "selected automatically). Other CLR types are not supported — convert the value to a string " + + "or byte[] before encrypting.", + nameof(propertyBuilder)); + } + return propertyBuilder.HasConversion(new EncryptedStringConverter(protector)); } diff --git a/src/PostQuantum.EntityFrameworkCore/IPostQuantumProtector.cs b/src/PostQuantum.EntityFrameworkCore/IPostQuantumProtector.cs index f2cf525..ae75a15 100644 --- a/src/PostQuantum.EntityFrameworkCore/IPostQuantumProtector.cs +++ b/src/PostQuantum.EntityFrameworkCore/IPostQuantumProtector.cs @@ -67,12 +67,17 @@ internal PostQuantumProtector( } } - if (!_handlers.ContainsKey(defaultScheme)) + if (!_handlers.TryGetValue(defaultScheme, out IEncryptionSchemeHandler? defaultHandler)) { throw new ArgumentException( $"No handler is registered for the default scheme {defaultScheme}.", nameof(defaultScheme)); } + // Fail fast: confirm the scheme used for new writes is actually usable on this + // platform and has an active key, so a misconfiguration surfaces at construction + // (typically application startup) rather than on the first SaveChanges. + defaultHandler.ValidateReady(); + DefaultScheme = defaultScheme; } diff --git a/src/PostQuantum.EntityFrameworkCore/Keys/InMemoryDataProtectionKeyRing.cs b/src/PostQuantum.EntityFrameworkCore/Keys/InMemoryDataProtectionKeyRing.cs index d7ffa76..8b36e9a 100644 --- a/src/PostQuantum.EntityFrameworkCore/Keys/InMemoryDataProtectionKeyRing.cs +++ b/src/PostQuantum.EntityFrameworkCore/Keys/InMemoryDataProtectionKeyRing.cs @@ -1,3 +1,5 @@ +using System.Collections.Concurrent; + namespace PostQuantum.EntityFrameworkCore.Keys; /// @@ -12,11 +14,19 @@ namespace PostQuantum.EntityFrameworkCore.Keys; /// over a managed key store such as /// PostQuantum.KeyManagement, an HSM, or a cloud KMS. /// +/// +/// Rotation. Rotate in place with and +/// on the ring instance the protector already holds — do not +/// build a new protector or ring to rotate. Entity Framework Core caches the model, and the +/// value converters in that cached model capture the protector instance; swapping the +/// protector has no effect until the model cache is invalidated. Mutating this ring is +/// thread-safe and is observed immediately by the singleton protector. +/// /// public sealed class InMemoryDataProtectionKeyRing : IDataProtectionKeyRing, IDisposable { - private readonly Dictionary _keys; - private readonly string _activeKeyId; + private readonly ConcurrentDictionary _keys; + private volatile string _activeKeyId; private bool _disposed; /// @@ -29,7 +39,7 @@ public InMemoryDataProtectionKeyRing(string activeKeyId, IEnumerable(StringComparer.Ordinal); + _keys = new ConcurrentDictionary(StringComparer.Ordinal); foreach (DataEncryptionKey key in keys) { if (!_keys.TryAdd(key.KeyId, key)) @@ -71,6 +81,65 @@ public DataEncryptionKey ActiveKey return _keys.GetValueOrDefault(keyId); } + /// + /// Adds a key to the ring (for example a freshly generated key during rotation) without + /// changing which key is active. Historical values keep decrypting; new writes are + /// unaffected until is called. Thread-safe. + /// + /// A key with the same id is already present. + public void AddKey(DataEncryptionKey key) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(key); + if (!_keys.TryAdd(key.KeyId, key)) + { + throw new ArgumentException($"A key with id '{key.KeyId}' is already in the ring.", nameof(key)); + } + } + + /// + /// Makes an already-present key the active key for new writes. Combine with + /// to rotate: add the new key, then activate it. Thread-safe; the + /// change is observed immediately by the protector that holds this ring. + /// + /// No key with this id is in the ring. + public void SetActiveKey(string keyId) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentException.ThrowIfNullOrWhiteSpace(keyId); + if (!_keys.ContainsKey(keyId)) + { + throw new ArgumentException($"No key with id '{keyId}' is in the ring; add it first.", nameof(keyId)); + } + + _activeKeyId = keyId; + } + + /// + /// Removes (retires) a non-active key once nothing is encrypted under it — typically after + /// a re-encryption sweep. The active key cannot be removed. The removed key is disposed + /// (its material zeroed). Thread-safe. + /// + /// if a key was removed. + /// An attempt was made to remove the active key. + public bool RemoveKey(string keyId) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentException.ThrowIfNullOrWhiteSpace(keyId); + if (string.Equals(keyId, _activeKeyId, StringComparison.Ordinal)) + { + throw new ArgumentException("Cannot remove the active key; activate another key first.", nameof(keyId)); + } + + if (_keys.TryRemove(keyId, out DataEncryptionKey? removed)) + { + removed.Dispose(); + return true; + } + + return false; + } + /// Zeroes and disposes every key held by the ring. public void Dispose() { diff --git a/src/PostQuantum.EntityFrameworkCore/Keys/InMemoryKeyEncapsulationKeyRing.cs b/src/PostQuantum.EntityFrameworkCore/Keys/InMemoryKeyEncapsulationKeyRing.cs index e5c8973..7325a75 100644 --- a/src/PostQuantum.EntityFrameworkCore/Keys/InMemoryKeyEncapsulationKeyRing.cs +++ b/src/PostQuantum.EntityFrameworkCore/Keys/InMemoryKeyEncapsulationKeyRing.cs @@ -1,3 +1,5 @@ +using System.Collections.Concurrent; + namespace PostQuantum.EntityFrameworkCore.Keys; /// @@ -5,15 +7,23 @@ namespace PostQuantum.EntityFrameworkCore.Keys; /// self-hosted deployments. /// /// +/// /// Security note: private decapsulation keys held here live in process memory and /// are zeroed on . For production, custody private keys in /// PostQuantum.KeyManagement, an HSM, or a cloud KMS and implement this interface /// over that store. +/// +/// +/// Rotation. Rotate in place with and +/// on the ring the protector already holds; see the note on +/// for why a new ring/protector would have no +/// effect with EF Core's model cache. Thread-safe. +/// /// public sealed class InMemoryKeyEncapsulationKeyRing : IKeyEncapsulationKeyRing, IDisposable { - private readonly Dictionary _keys; - private readonly string _activeKeyId; + private readonly ConcurrentDictionary _keys; + private volatile string _activeKeyId; private bool _disposed; /// Creates a ring from a set of key pairs, designating one as active. @@ -22,7 +32,7 @@ public InMemoryKeyEncapsulationKeyRing(string activeKeyId, IEnumerable(StringComparer.Ordinal); + _keys = new ConcurrentDictionary(StringComparer.Ordinal); foreach (KeyEncapsulationKeyPair key in keys) { if (!_keys.TryAdd(key.KeyId, key)) @@ -64,6 +74,56 @@ public KeyEncapsulationKeyPair ActiveKey return _keys.GetValueOrDefault(keyId); } + /// Adds a key pair to the ring without changing the active key. Thread-safe. + /// A pair with the same id is already present. + public void AddKey(KeyEncapsulationKeyPair key) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(key); + if (!_keys.TryAdd(key.KeyId, key)) + { + throw new ArgumentException($"A key with id '{key.KeyId}' is already in the ring.", nameof(key)); + } + } + + /// Makes an already-present pair the active key for new writes. Thread-safe. + /// No pair with this id is in the ring. + public void SetActiveKey(string keyId) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentException.ThrowIfNullOrWhiteSpace(keyId); + if (!_keys.ContainsKey(keyId)) + { + throw new ArgumentException($"No key with id '{keyId}' is in the ring; add it first.", nameof(keyId)); + } + + _activeKeyId = keyId; + } + + /// + /// Removes (retires) a non-active pair once nothing is encrypted under it. The active key + /// cannot be removed. The removed pair is disposed (private material zeroed). Thread-safe. + /// + /// if a pair was removed. + /// An attempt was made to remove the active key. + public bool RemoveKey(string keyId) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentException.ThrowIfNullOrWhiteSpace(keyId); + if (string.Equals(keyId, _activeKeyId, StringComparison.Ordinal)) + { + throw new ArgumentException("Cannot remove the active key; activate another key first.", nameof(keyId)); + } + + if (_keys.TryRemove(keyId, out KeyEncapsulationKeyPair? removed)) + { + removed.Dispose(); + return true; + } + + return false; + } + /// Zeroes and disposes every key pair held by the ring. public void Dispose() { diff --git a/src/PostQuantum.EntityFrameworkCore/PostQuantum.EntityFrameworkCore.csproj b/src/PostQuantum.EntityFrameworkCore/PostQuantum.EntityFrameworkCore.csproj index 440bc8f..c69ba8a 100644 --- a/src/PostQuantum.EntityFrameworkCore/PostQuantum.EntityFrameworkCore.csproj +++ b/src/PostQuantum.EntityFrameworkCore/PostQuantum.EntityFrameworkCore.csproj @@ -16,12 +16,12 @@ PostQuantum.EntityFrameworkCore - 0.1.0 + 1.0.0 PostQuantum.EntityFrameworkCore Secure-by-default, post-quantum encryption for sensitive data at rest in Entity Framework Core. Encrypt individual properties (emails, PII, financial and medical data) with authenticated AES-256-GCM, optionally wrapped in an ML-KEM-768 (FIPS 203) hybrid envelope for post-quantum key protection. Clean value-converter API, pluggable key rings for integration with PostQuantum.KeyManagement, honest threat model, and transparent supply chain. post-quantum;postquantum;pqc;encryption;ml-kem;mlkem;kyber;fips203;entity-framework;entityframeworkcore;efcore;encryption-at-rest;column-encryption;field-encryption;pii;aes-gcm;security;cryptography README.md - v0.1.0 — Initial release. AES-256-GCM field encryption with authenticated envelopes, ML-KEM-768 hybrid key-wrapping (feature-detected), EF Core value-converter integration, pluggable key rings, and full threat-model documentation. See CHANGELOG.md. + v1.0.0 — First stable release. Frozen, authenticated PQE1 envelope (now HPKE-style: the hybrid scheme authenticates the full ML-KEM encapsulation in format v2, reads v1); fail-fast startup validation of the default scheme; clear errors when IsEncrypted is misapplied; in-place key rotation on the in-memory rings plus ReEncryptAsync re-encryption sweep; tracked public API surface; expanded security tests. SemVer API and format stability for 1.x. See CHANGELOG.md. @@ -36,6 +36,13 @@ + + + + + + + diff --git a/src/PostQuantum.EntityFrameworkCore/PublicAPI.Shipped.txt b/src/PostQuantum.EntityFrameworkCore/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7516c61 --- /dev/null +++ b/src/PostQuantum.EntityFrameworkCore/PublicAPI.Shipped.txt @@ -0,0 +1,96 @@ +#nullable enable +const PostQuantum.EntityFrameworkCore.Crypto.MLKemKeyEncapsulationMechanism.Algorithm = "ML-KEM-768" -> string! +const PostQuantum.EntityFrameworkCore.Keys.DataEncryptionKey.KeySizeInBytes = 32 -> int +PostQuantum.EntityFrameworkCore.Crypto.EncapsulationResult +PostQuantum.EntityFrameworkCore.Crypto.EncapsulationResult.Ciphertext.get -> byte[]! +PostQuantum.EntityFrameworkCore.Crypto.EncapsulationResult.EncapsulationResult() -> void +PostQuantum.EntityFrameworkCore.Crypto.EncapsulationResult.EncapsulationResult(byte[]! ciphertext, byte[]! sharedSecret) -> void +PostQuantum.EntityFrameworkCore.Crypto.EncapsulationResult.SharedSecret.get -> byte[]! +PostQuantum.EntityFrameworkCore.Crypto.EncryptionScheme +PostQuantum.EntityFrameworkCore.Crypto.EncryptionScheme.Aes256Gcm = 1 -> PostQuantum.EntityFrameworkCore.Crypto.EncryptionScheme +PostQuantum.EntityFrameworkCore.Crypto.EncryptionScheme.MLKem768Aes256Gcm = 2 -> PostQuantum.EntityFrameworkCore.Crypto.EncryptionScheme +PostQuantum.EntityFrameworkCore.Crypto.IKeyEncapsulationMechanism +PostQuantum.EntityFrameworkCore.Crypto.IKeyEncapsulationMechanism.AlgorithmName.get -> string! +PostQuantum.EntityFrameworkCore.Crypto.IKeyEncapsulationMechanism.Decapsulate(PostQuantum.EntityFrameworkCore.Keys.KeyEncapsulationKeyPair! privateKey, System.ReadOnlySpan ciphertext) -> byte[]! +PostQuantum.EntityFrameworkCore.Crypto.IKeyEncapsulationMechanism.Encapsulate(PostQuantum.EntityFrameworkCore.Keys.KeyEncapsulationKeyPair! publicKey) -> PostQuantum.EntityFrameworkCore.Crypto.EncapsulationResult +PostQuantum.EntityFrameworkCore.Crypto.IKeyEncapsulationMechanism.GenerateKeyPair(string! keyId) -> PostQuantum.EntityFrameworkCore.Keys.KeyEncapsulationKeyPair! +PostQuantum.EntityFrameworkCore.Crypto.IKeyEncapsulationMechanism.IsSupported.get -> bool +PostQuantum.EntityFrameworkCore.Crypto.MLKemKeyEncapsulationMechanism +PostQuantum.EntityFrameworkCore.Crypto.MLKemKeyEncapsulationMechanism.AlgorithmName.get -> string! +PostQuantum.EntityFrameworkCore.Crypto.MLKemKeyEncapsulationMechanism.Decapsulate(PostQuantum.EntityFrameworkCore.Keys.KeyEncapsulationKeyPair! privateKey, System.ReadOnlySpan ciphertext) -> byte[]! +PostQuantum.EntityFrameworkCore.Crypto.MLKemKeyEncapsulationMechanism.Encapsulate(PostQuantum.EntityFrameworkCore.Keys.KeyEncapsulationKeyPair! publicKey) -> PostQuantum.EntityFrameworkCore.Crypto.EncapsulationResult +PostQuantum.EntityFrameworkCore.Crypto.MLKemKeyEncapsulationMechanism.GenerateKeyPair(string! keyId) -> PostQuantum.EntityFrameworkCore.Keys.KeyEncapsulationKeyPair! +PostQuantum.EntityFrameworkCore.Crypto.MLKemKeyEncapsulationMechanism.IsSupported.get -> bool +PostQuantum.EntityFrameworkCore.Crypto.MLKemKeyEncapsulationMechanism.MLKemKeyEncapsulationMechanism() -> void +PostQuantum.EntityFrameworkCore.DependencyInjection.PostQuantumEncryptionBuilder +PostQuantum.EntityFrameworkCore.DependencyInjection.PostQuantumEncryptionBuilder.Services.get -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +PostQuantum.EntityFrameworkCore.DependencyInjection.PostQuantumEncryptionBuilder.UseAes256Gcm(PostQuantum.EntityFrameworkCore.Keys.IDataProtectionKeyRing! keyRing, bool asDefault = true) -> PostQuantum.EntityFrameworkCore.DependencyInjection.PostQuantumEncryptionBuilder! +PostQuantum.EntityFrameworkCore.DependencyInjection.PostQuantumEncryptionBuilder.UseAes256Gcm(System.Func! keyRingFactory, bool asDefault = true) -> PostQuantum.EntityFrameworkCore.DependencyInjection.PostQuantumEncryptionBuilder! +PostQuantum.EntityFrameworkCore.DependencyInjection.PostQuantumEncryptionBuilder.UseKeyEncapsulationMechanism(PostQuantum.EntityFrameworkCore.Crypto.IKeyEncapsulationMechanism! mechanism) -> PostQuantum.EntityFrameworkCore.DependencyInjection.PostQuantumEncryptionBuilder! +PostQuantum.EntityFrameworkCore.DependencyInjection.PostQuantumEncryptionBuilder.UseMLKem768Envelope(PostQuantum.EntityFrameworkCore.Keys.IKeyEncapsulationKeyRing! keyRing, bool asDefault = true) -> PostQuantum.EntityFrameworkCore.DependencyInjection.PostQuantumEncryptionBuilder! +PostQuantum.EntityFrameworkCore.DependencyInjection.PostQuantumEncryptionBuilder.UseMLKem768Envelope(System.Func! keyRingFactory, bool asDefault = true) -> PostQuantum.EntityFrameworkCore.DependencyInjection.PostQuantumEncryptionBuilder! +PostQuantum.EntityFrameworkCore.DependencyInjection.ServiceCollectionExtensions +PostQuantum.EntityFrameworkCore.EntityFrameworkCore.EncryptedBinaryConverter +PostQuantum.EntityFrameworkCore.EntityFrameworkCore.EncryptedBinaryConverter.EncryptedBinaryConverter(PostQuantum.EntityFrameworkCore.IPostQuantumProtector! protector, Microsoft.EntityFrameworkCore.Storage.ValueConversion.ConverterMappingHints? mappingHints = null) -> void +PostQuantum.EntityFrameworkCore.EntityFrameworkCore.EncryptedDataMaintenance +PostQuantum.EntityFrameworkCore.EntityFrameworkCore.EncryptedPropertyBuilderExtensions +PostQuantum.EntityFrameworkCore.EntityFrameworkCore.EncryptedStringConverter +PostQuantum.EntityFrameworkCore.EntityFrameworkCore.EncryptedStringConverter.EncryptedStringConverter(PostQuantum.EntityFrameworkCore.IPostQuantumProtector! protector, Microsoft.EntityFrameworkCore.Storage.ValueConversion.ConverterMappingHints? mappingHints = null) -> void +PostQuantum.EntityFrameworkCore.IPostQuantumProtector +PostQuantum.EntityFrameworkCore.IPostQuantumProtector.DefaultScheme.get -> PostQuantum.EntityFrameworkCore.Crypto.EncryptionScheme +PostQuantum.EntityFrameworkCore.IPostQuantumProtector.Protect(System.ReadOnlySpan plaintext) -> byte[]! +PostQuantum.EntityFrameworkCore.IPostQuantumProtector.ProtectText(string! plaintext) -> byte[]! +PostQuantum.EntityFrameworkCore.IPostQuantumProtector.Unprotect(System.ReadOnlyMemory protectedData) -> byte[]! +PostQuantum.EntityFrameworkCore.IPostQuantumProtector.UnprotectText(System.ReadOnlyMemory protectedData) -> string! +PostQuantum.EntityFrameworkCore.Keys.DataEncryptionKey +PostQuantum.EntityFrameworkCore.Keys.DataEncryptionKey.DataEncryptionKey(string! keyId, System.ReadOnlySpan material) -> void +PostQuantum.EntityFrameworkCore.Keys.DataEncryptionKey.Dispose() -> void +PostQuantum.EntityFrameworkCore.Keys.DataEncryptionKey.KeyId.get -> string! +PostQuantum.EntityFrameworkCore.Keys.IDataProtectionKeyRing +PostQuantum.EntityFrameworkCore.Keys.IDataProtectionKeyRing.ActiveKey.get -> PostQuantum.EntityFrameworkCore.Keys.DataEncryptionKey! +PostQuantum.EntityFrameworkCore.Keys.IDataProtectionKeyRing.Find(string! keyId) -> PostQuantum.EntityFrameworkCore.Keys.DataEncryptionKey? +PostQuantum.EntityFrameworkCore.Keys.IKeyEncapsulationKeyRing +PostQuantum.EntityFrameworkCore.Keys.IKeyEncapsulationKeyRing.ActiveKey.get -> PostQuantum.EntityFrameworkCore.Keys.KeyEncapsulationKeyPair! +PostQuantum.EntityFrameworkCore.Keys.IKeyEncapsulationKeyRing.Find(string! keyId) -> PostQuantum.EntityFrameworkCore.Keys.KeyEncapsulationKeyPair? +PostQuantum.EntityFrameworkCore.Keys.InMemoryDataProtectionKeyRing +PostQuantum.EntityFrameworkCore.Keys.InMemoryDataProtectionKeyRing.ActiveKey.get -> PostQuantum.EntityFrameworkCore.Keys.DataEncryptionKey! +PostQuantum.EntityFrameworkCore.Keys.InMemoryDataProtectionKeyRing.AddKey(PostQuantum.EntityFrameworkCore.Keys.DataEncryptionKey! key) -> void +PostQuantum.EntityFrameworkCore.Keys.InMemoryDataProtectionKeyRing.Dispose() -> void +PostQuantum.EntityFrameworkCore.Keys.InMemoryDataProtectionKeyRing.Find(string! keyId) -> PostQuantum.EntityFrameworkCore.Keys.DataEncryptionKey? +PostQuantum.EntityFrameworkCore.Keys.InMemoryDataProtectionKeyRing.InMemoryDataProtectionKeyRing(PostQuantum.EntityFrameworkCore.Keys.DataEncryptionKey! key) -> void +PostQuantum.EntityFrameworkCore.Keys.InMemoryDataProtectionKeyRing.InMemoryDataProtectionKeyRing(string! activeKeyId, System.Collections.Generic.IEnumerable! keys) -> void +PostQuantum.EntityFrameworkCore.Keys.InMemoryDataProtectionKeyRing.RemoveKey(string! keyId) -> bool +PostQuantum.EntityFrameworkCore.Keys.InMemoryDataProtectionKeyRing.SetActiveKey(string! keyId) -> void +PostQuantum.EntityFrameworkCore.Keys.InMemoryKeyEncapsulationKeyRing +PostQuantum.EntityFrameworkCore.Keys.InMemoryKeyEncapsulationKeyRing.ActiveKey.get -> PostQuantum.EntityFrameworkCore.Keys.KeyEncapsulationKeyPair! +PostQuantum.EntityFrameworkCore.Keys.InMemoryKeyEncapsulationKeyRing.AddKey(PostQuantum.EntityFrameworkCore.Keys.KeyEncapsulationKeyPair! key) -> void +PostQuantum.EntityFrameworkCore.Keys.InMemoryKeyEncapsulationKeyRing.Dispose() -> void +PostQuantum.EntityFrameworkCore.Keys.InMemoryKeyEncapsulationKeyRing.Find(string! keyId) -> PostQuantum.EntityFrameworkCore.Keys.KeyEncapsulationKeyPair? +PostQuantum.EntityFrameworkCore.Keys.InMemoryKeyEncapsulationKeyRing.InMemoryKeyEncapsulationKeyRing(PostQuantum.EntityFrameworkCore.Keys.KeyEncapsulationKeyPair! key) -> void +PostQuantum.EntityFrameworkCore.Keys.InMemoryKeyEncapsulationKeyRing.InMemoryKeyEncapsulationKeyRing(string! activeKeyId, System.Collections.Generic.IEnumerable! keys) -> void +PostQuantum.EntityFrameworkCore.Keys.InMemoryKeyEncapsulationKeyRing.RemoveKey(string! keyId) -> bool +PostQuantum.EntityFrameworkCore.Keys.InMemoryKeyEncapsulationKeyRing.SetActiveKey(string! keyId) -> void +PostQuantum.EntityFrameworkCore.Keys.KeyEncapsulationKeyPair +PostQuantum.EntityFrameworkCore.Keys.KeyEncapsulationKeyPair.AlgorithmName.get -> string! +PostQuantum.EntityFrameworkCore.Keys.KeyEncapsulationKeyPair.CanDecapsulate.get -> bool +PostQuantum.EntityFrameworkCore.Keys.KeyEncapsulationKeyPair.Dispose() -> void +PostQuantum.EntityFrameworkCore.Keys.KeyEncapsulationKeyPair.KeyEncapsulationKeyPair(string! keyId, string! algorithmName, System.ReadOnlySpan encapsulationKey, System.ReadOnlySpan decapsulationKey = default(System.ReadOnlySpan)) -> void +PostQuantum.EntityFrameworkCore.Keys.KeyEncapsulationKeyPair.KeyId.get -> string! +PostQuantum.EntityFrameworkCore.PostQuantumCryptographicException +PostQuantum.EntityFrameworkCore.PostQuantumCryptographicException.PostQuantumCryptographicException(string! message) -> void +PostQuantum.EntityFrameworkCore.PostQuantumCryptographicException.PostQuantumCryptographicException(string! message, System.Exception! innerException) -> void +PostQuantum.EntityFrameworkCore.PostQuantumProtector +PostQuantum.EntityFrameworkCore.PostQuantumProtector.DefaultScheme.get -> PostQuantum.EntityFrameworkCore.Crypto.EncryptionScheme +PostQuantum.EntityFrameworkCore.PostQuantumProtector.Protect(System.ReadOnlySpan plaintext) -> byte[]! +PostQuantum.EntityFrameworkCore.PostQuantumProtector.ProtectText(string! plaintext) -> byte[]! +PostQuantum.EntityFrameworkCore.PostQuantumProtector.Unprotect(System.ReadOnlyMemory protectedData) -> byte[]! +PostQuantum.EntityFrameworkCore.PostQuantumProtector.UnprotectText(System.ReadOnlyMemory protectedData) -> string! +PostQuantum.EntityFrameworkCore.PostQuantumProtectorExtensions +static PostQuantum.EntityFrameworkCore.DependencyInjection.ServiceCollectionExtensions.AddPostQuantumEncryption(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static PostQuantum.EntityFrameworkCore.EntityFrameworkCore.EncryptedDataMaintenance.MarkEncryptedPropertiesModified(this Microsoft.EntityFrameworkCore.DbContext! context, TEntity! entity) -> int +static PostQuantum.EntityFrameworkCore.EntityFrameworkCore.EncryptedDataMaintenance.ReEncryptAsync(this Microsoft.EntityFrameworkCore.DbContext! context, int batchSize = 500, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static PostQuantum.EntityFrameworkCore.EntityFrameworkCore.EncryptedPropertyBuilderExtensions.IsEncrypted(this Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder! propertyBuilder, PostQuantum.EntityFrameworkCore.IPostQuantumProtector! protector) -> Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder! +static PostQuantum.EntityFrameworkCore.EntityFrameworkCore.EncryptedPropertyBuilderExtensions.IsEncrypted(this Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder! propertyBuilder, PostQuantum.EntityFrameworkCore.IPostQuantumProtector! protector) -> Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder! +static PostQuantum.EntityFrameworkCore.Keys.DataEncryptionKey.Generate(string! keyId) -> PostQuantum.EntityFrameworkCore.Keys.DataEncryptionKey! +static PostQuantum.EntityFrameworkCore.PostQuantumProtectorExtensions.ProtectBytes(this PostQuantum.EntityFrameworkCore.IPostQuantumProtector! protector, byte[]! plaintext) -> byte[]! +static PostQuantum.EntityFrameworkCore.PostQuantumProtectorExtensions.UnprotectBytes(this PostQuantum.EntityFrameworkCore.IPostQuantumProtector! protector, byte[]! protectedData) -> byte[]! diff --git a/src/PostQuantum.EntityFrameworkCore/PublicAPI.Unshipped.txt b/src/PostQuantum.EntityFrameworkCore/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/PostQuantum.EntityFrameworkCore/PublicAPI.Unshipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/tests/PostQuantum.EntityFrameworkCore.Tests/EnvelopeHardeningTests.cs b/tests/PostQuantum.EntityFrameworkCore.Tests/EnvelopeHardeningTests.cs new file mode 100644 index 0000000..017489f --- /dev/null +++ b/tests/PostQuantum.EntityFrameworkCore.Tests/EnvelopeHardeningTests.cs @@ -0,0 +1,94 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using System.Text; +using PostQuantum.EntityFrameworkCore.Crypto; +using PostQuantum.EntityFrameworkCore.Keys; +using Xunit; + +namespace PostQuantum.EntityFrameworkCore.Tests; + +/// +/// Covers the version-2 hybrid envelope, which folds the KEM encapsulation block into the +/// AES-GCM associated data so the whole encapsulation is authenticated, while still reading +/// version-1 hybrid envelopes written by 0.1.0. +/// +public class EnvelopeHardeningTests +{ + private const string HybridHkdfInfo = "PQEF/ML-KEM-768+AES-256-GCM/v1"; + + [Fact] + public void Hybrid_envelope_is_written_as_format_version_2() + { + IPostQuantumProtector protector = TestKeys.EnvelopeProtector(new FakeKeyEncapsulationMechanism()); + + byte[] envelope = protector.ProtectText("phi"); + + Assert.Equal(EncryptedEnvelope.HybridFormatVersion, envelope[EncryptedEnvelope.VersionOffset]); + } + + [Fact] + public void Aes_envelope_remains_format_version_1() + { + IPostQuantumProtector protector = TestKeys.AesProtector(); + + byte[] envelope = protector.ProtectText("pii"); + + Assert.Equal(EncryptedEnvelope.FormatVersion, envelope[EncryptedEnvelope.VersionOffset]); + } + + [Fact] + public void Tampering_a_kem_ciphertext_byte_fails_authentication() + { + // In v2 the KEM ciphertext is part of the associated data, so flipping a byte inside + // it must fail the AEAD tag (defence in depth beyond the derived-key mismatch). + IPostQuantumProtector protector = TestKeys.EnvelopeProtector(new FakeKeyEncapsulationMechanism()); + byte[] envelope = protector.ProtectText("sensitive"); + + // The KEM ciphertext begins two bytes into the body (after its big-endian length). + int bodyOffset = EncryptedEnvelope.Parse(envelope).AssociatedData.Length; + envelope[bodyOffset + 2] ^= 0xFF; + + Assert.Throws(() => protector.UnprotectText(envelope)); + } + + [Fact] + public void Legacy_version_1_hybrid_envelope_still_decrypts() + { + // Reproduce exactly what 0.1.0 wrote: a version-1 header, the KEM block in the body, + // and a DEM whose associated data is the header ONLY (no KEM block). The current + // handler must still read it by rebuilding the version-1 associated data. + const string plaintext = "written-by-0.1.0"; + const string keyId = "kek-legacy"; + var kem = new FakeKeyEncapsulationMechanism(); + KeyEncapsulationKeyPair pair = kem.GenerateKeyPair(keyId); + EncapsulationResult encapsulation = kem.Encapsulate(pair); + + byte[] header = EncryptedEnvelope.WriteHeader( + EncryptionScheme.MLKem768Aes256Gcm, keyId, EncryptedEnvelope.FormatVersion); + + Span dek = stackalloc byte[32]; + HKDF.DeriveKey( + HashAlgorithmName.SHA256, + ikm: encapsulation.SharedSecret, + output: dek, + salt: Encoding.UTF8.GetBytes(keyId), + info: Encoding.ASCII.GetBytes(HybridHkdfInfo)); + + // v1 associated data = header only. + byte[] dem = AuthenticatedCipher.Encrypt(dek, Encoding.UTF8.GetBytes(plaintext), header); + + byte[] ciphertext = encapsulation.Ciphertext; + var envelope = new byte[header.Length + 2 + ciphertext.Length + dem.Length]; + header.CopyTo(envelope.AsSpan()); + BinaryPrimitives.WriteUInt16BigEndian(envelope.AsSpan(header.Length, 2), (ushort)ciphertext.Length); + ciphertext.CopyTo(envelope.AsSpan(header.Length + 2)); + dem.CopyTo(envelope.AsSpan(header.Length + 2 + ciphertext.Length)); + + var protector = new PostQuantumProtector( + [new MLKemEnvelopeSchemeHandler(new InMemoryKeyEncapsulationKeyRing(pair), kem)], + EncryptionScheme.MLKem768Aes256Gcm); + + Assert.Equal(EncryptedEnvelope.FormatVersion, envelope[EncryptedEnvelope.VersionOffset]); + Assert.Equal(plaintext, protector.UnprotectText(envelope)); + } +} diff --git a/tests/PostQuantum.EntityFrameworkCore.Tests/FailFastValidationTests.cs b/tests/PostQuantum.EntityFrameworkCore.Tests/FailFastValidationTests.cs new file mode 100644 index 0000000..2e5ff26 --- /dev/null +++ b/tests/PostQuantum.EntityFrameworkCore.Tests/FailFastValidationTests.cs @@ -0,0 +1,89 @@ +using Microsoft.Extensions.DependencyInjection; +using PostQuantum.EntityFrameworkCore.Crypto; +using PostQuantum.EntityFrameworkCore.DependencyInjection; +using PostQuantum.EntityFrameworkCore.Keys; +using Xunit; + +namespace PostQuantum.EntityFrameworkCore.Tests; + +/// +/// Verifies that a protector whose default scheme cannot run on this platform fails at +/// construction (startup) rather than on the first encrypt. +/// +public class FailFastValidationTests +{ + [Fact] + public void Constructing_with_an_unsupported_default_kem_throws_platform_not_supported() + { + var kem = new UnsupportedKeyEncapsulationMechanism(); + var pair = new KeyEncapsulationKeyPair("kek-1", kem.AlgorithmName, new byte[32]); + var ring = new InMemoryKeyEncapsulationKeyRing(pair); + + PlatformNotSupportedException ex = Assert.Throws(() => + new PostQuantumProtector( + [new MLKemEnvelopeSchemeHandler(ring, kem)], + EncryptionScheme.MLKem768Aes256Gcm)); + + Assert.Contains("platform", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void A_supported_default_scheme_constructs_normally() + { + IPostQuantumProtector protector = TestKeys.EnvelopeProtector(new FakeKeyEncapsulationMechanism()); + Assert.Equal(EncryptionScheme.MLKem768Aes256Gcm, protector.DefaultScheme); + } + + [Fact] + public void Unsupported_kem_as_non_default_does_not_block_an_aes_default() + { + // ML-KEM registered only to read legacy values; AES is the default for new writes. + // Validation only touches the default scheme, so an unsupported ML-KEM must not throw. + using DataEncryptionKey dek = DataEncryptionKey.Generate("dek-1"); + var kem = new UnsupportedKeyEncapsulationMechanism(); + var pair = new KeyEncapsulationKeyPair("kek-1", kem.AlgorithmName, new byte[32]); + + var protector = new PostQuantumProtector( + [ + new MLKemEnvelopeSchemeHandler(new InMemoryKeyEncapsulationKeyRing(pair), kem), + new Aes256GcmSchemeHandler(new InMemoryDataProtectionKeyRing(dek)), + ], + EncryptionScheme.Aes256Gcm); + + Assert.Equal("ok", protector.UnprotectText(protector.ProtectText("ok"))); + } + + [Fact] + public void Resolving_from_di_with_unsupported_default_kem_throws_platform_not_supported() + { + var kem = new UnsupportedKeyEncapsulationMechanism(); + var pair = new KeyEncapsulationKeyPair("kek-1", kem.AlgorithmName, new byte[32]); + + var services = new ServiceCollection(); + services.AddPostQuantumEncryption(pq => + { + pq.UseKeyEncapsulationMechanism(kem); + pq.UseMLKem768Envelope(new InMemoryKeyEncapsulationKeyRing(pair)); + }); + + using ServiceProvider provider = services.BuildServiceProvider(); + + Assert.Throws( + () => provider.GetRequiredService()); + } +} + +/// A KEM that reports itself unavailable, modelling a platform without ML-KEM. +internal sealed class UnsupportedKeyEncapsulationMechanism : IKeyEncapsulationMechanism +{ + public string AlgorithmName => "ML-KEM-768"; + + public bool IsSupported => false; + + public KeyEncapsulationKeyPair GenerateKeyPair(string keyId) => throw new PlatformNotSupportedException(); + + public EncapsulationResult Encapsulate(KeyEncapsulationKeyPair publicKey) => throw new PlatformNotSupportedException(); + + public byte[] Decapsulate(KeyEncapsulationKeyPair privateKey, ReadOnlySpan ciphertext) => + throw new PlatformNotSupportedException(); +} diff --git a/tests/PostQuantum.EntityFrameworkCore.Tests/IsEncryptedGuardTests.cs b/tests/PostQuantum.EntityFrameworkCore.Tests/IsEncryptedGuardTests.cs new file mode 100644 index 0000000..192ca8b --- /dev/null +++ b/tests/PostQuantum.EntityFrameworkCore.Tests/IsEncryptedGuardTests.cs @@ -0,0 +1,50 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using PostQuantum.EntityFrameworkCore.Crypto; +using PostQuantum.EntityFrameworkCore.EntityFrameworkCore; +using PostQuantum.EntityFrameworkCore.Keys; +using Xunit; + +namespace PostQuantum.EntityFrameworkCore.Tests; + +/// +/// Verifies that IsEncrypted rejects unsupported property types with a clear, +/// property-named error instead of an opaque EF Core model-build failure. +/// +public class IsEncryptedGuardTests +{ + private sealed class Widget + { + public int Id { get; set; } + public int Quantity { get; set; } // not a string or byte[] + } + + private sealed class BadContext(DbContextOptions options, IPostQuantumProtector protector) + : DbContext(options) + { + private readonly IPostQuantumProtector _protector = protector; + + public DbSet Widgets => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + // Encrypting an int property is a mistake; the guard must catch it. + modelBuilder.Entity().Property(w => w.Quantity).IsEncrypted(_protector); + } + } + + [Fact] + public void IsEncrypted_on_a_non_string_non_binary_property_throws_a_clear_error() + { + IPostQuantumProtector protector = TestKeys.AesProtector(); + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite("DataSource=:memory:") + .Options; + + using var ctx = new BadContext(options, protector); + + ArgumentException ex = Assert.Throws(() => _ = ctx.Model); + Assert.Contains("Quantity", ex.Message, StringComparison.Ordinal); + Assert.Contains("byte[]", ex.Message, StringComparison.Ordinal); + } +} diff --git a/tests/PostQuantum.EntityFrameworkCore.Tests/KeyRingRotationTests.cs b/tests/PostQuantum.EntityFrameworkCore.Tests/KeyRingRotationTests.cs new file mode 100644 index 0000000..00f3b45 --- /dev/null +++ b/tests/PostQuantum.EntityFrameworkCore.Tests/KeyRingRotationTests.cs @@ -0,0 +1,66 @@ +using PostQuantum.EntityFrameworkCore.Keys; +using Xunit; + +namespace PostQuantum.EntityFrameworkCore.Tests; + +/// +/// Covers in-place rotation of the in-memory key rings: adding a key, activating it, and +/// retiring an old one, with the guard that the active key cannot be removed. +/// +public class KeyRingRotationTests +{ + [Fact] + public void AddKey_then_SetActiveKey_changes_the_active_key() + { + using var ring = new InMemoryDataProtectionKeyRing(DataEncryptionKey.Generate("dek-A")); + Assert.Equal("dek-A", ring.ActiveKey.KeyId); + + ring.AddKey(DataEncryptionKey.Generate("dek-B")); + Assert.Equal("dek-A", ring.ActiveKey.KeyId); // adding does not change active + + ring.SetActiveKey("dek-B"); + Assert.Equal("dek-B", ring.ActiveKey.KeyId); + Assert.NotNull(ring.Find("dek-A")); // old key still resolvable + } + + [Fact] + public void SetActiveKey_for_an_unknown_id_throws() + { + using var ring = new InMemoryDataProtectionKeyRing(DataEncryptionKey.Generate("dek-A")); + Assert.Throws(() => ring.SetActiveKey("dek-missing")); + } + + [Fact] + public void AddKey_rejects_a_duplicate_id() + { + using var ring = new InMemoryDataProtectionKeyRing(DataEncryptionKey.Generate("dek-A")); + Assert.Throws(() => ring.AddKey(DataEncryptionKey.Generate("dek-A"))); + } + + [Fact] + public void RemoveKey_retires_a_non_active_key_but_not_the_active_one() + { + using var ring = new InMemoryDataProtectionKeyRing(DataEncryptionKey.Generate("dek-A")); + ring.AddKey(DataEncryptionKey.Generate("dek-B")); + ring.SetActiveKey("dek-B"); + + Assert.True(ring.RemoveKey("dek-A")); + Assert.Null(ring.Find("dek-A")); + Assert.False(ring.RemoveKey("dek-A")); // already gone + Assert.Throws(() => ring.RemoveKey("dek-B")); // active key protected + } + + [Fact] + public void Kek_ring_supports_the_same_rotation_surface() + { + var kem = new FakeKeyEncapsulationMechanism(); + using var ring = new InMemoryKeyEncapsulationKeyRing(kem.GenerateKeyPair("kek-A")); + + ring.AddKey(kem.GenerateKeyPair("kek-B")); + ring.SetActiveKey("kek-B"); + Assert.Equal("kek-B", ring.ActiveKey.KeyId); + + Assert.True(ring.RemoveKey("kek-A")); + Assert.Throws(() => ring.RemoveKey("kek-B")); + } +} diff --git a/tests/PostQuantum.EntityFrameworkCore.Tests/ProtectorBehaviorTests.cs b/tests/PostQuantum.EntityFrameworkCore.Tests/ProtectorBehaviorTests.cs new file mode 100644 index 0000000..9bc81eb --- /dev/null +++ b/tests/PostQuantum.EntityFrameworkCore.Tests/ProtectorBehaviorTests.cs @@ -0,0 +1,67 @@ +using System.Collections.Concurrent; +using PostQuantum.EntityFrameworkCore.Crypto; +using Xunit; + +namespace PostQuantum.EntityFrameworkCore.Tests; + +/// +/// Locks in two documented behaviors of the protector: it is safe to share across threads +/// (it is registered as a singleton), and an intact envelope relocated to another location +/// that shares its key id still decrypts (a known, documented limitation). +/// +public class ProtectorBehaviorTests +{ + [Fact] + public void Protector_is_safe_for_concurrent_use() + { + IPostQuantumProtector protector = TestKeys.AesProtector(); + var failures = new ConcurrentBag(); + + Parallel.For(0, 2000, i => + { + string value = $"record-{i}"; + try + { + byte[] envelope = protector.ProtectText(value); + if (protector.UnprotectText(envelope) != value) + { + failures.Add(value); + } + } + catch (Exception ex) + { + failures.Add($"{value}: {ex.GetType().Name}"); + } + }); + + Assert.Empty(failures); + } + + [Fact] + public void An_intact_envelope_relocated_under_the_same_key_still_decrypts() + { + // DOCUMENTED LIMITATION (see KNOWN-GAPS.md / threat model): the associated data binds + // version, scheme, and key id — NOT the table, column, or row. So a whole valid + // envelope copied elsewhere (same key id) still decrypts. This test makes that + // behavior explicit so any future entity/property binding is a deliberate change. + IPostQuantumProtector protector = TestKeys.AesProtector(); + + byte[] ssn = protector.ProtectText("123-45-6789"); + + // Simulate an attacker with write access copying the SSN envelope into the email column. + byte[] relocated = (byte[])ssn.Clone(); + + Assert.Equal("123-45-6789", protector.UnprotectText(relocated)); + } + + [Fact] + public void Tampered_bytes_in_a_relocated_envelope_are_still_rejected() + { + IPostQuantumProtector protector = TestKeys.AesProtector(); + byte[] envelope = protector.ProtectText("123-45-6789"); + + envelope[^1] ^= 0x01; + + Assert.Throws(() => protector.UnprotectText(envelope)); + } +} diff --git a/tests/PostQuantum.EntityFrameworkCore.Tests/ReEncryptionTests.cs b/tests/PostQuantum.EntityFrameworkCore.Tests/ReEncryptionTests.cs new file mode 100644 index 0000000..bab9449 --- /dev/null +++ b/tests/PostQuantum.EntityFrameworkCore.Tests/ReEncryptionTests.cs @@ -0,0 +1,188 @@ +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using PostQuantum.EntityFrameworkCore.Crypto; +using PostQuantum.EntityFrameworkCore.EntityFrameworkCore; +using PostQuantum.EntityFrameworkCore.Keys; +using Xunit; + +namespace PostQuantum.EntityFrameworkCore.Tests; + +/// +/// Exercises the re-encryption helpers used to retire an old key after rotation, including +/// the subtlety that a plain load-and-save does not rewrite an unchanged encrypted value. +/// +/// +/// Each test uses a distinct context type because EF Core caches the model — and the +/// protector captured by its value converters — globally per context CLR type. Rotation is +/// performed in place on the single ring the protector holds (the only path that works with +/// that cache), mirroring production use. +/// +public class ReEncryptionTests +{ + private sealed class Record + { + public int Id { get; set; } + public string Email { get; set; } = ""; // encrypted string + public byte[] Scan { get; set; } = []; // encrypted byte[] + } + + private abstract class RecordContextBase(DbContextOptions options, IPostQuantumProtector protector) + : DbContext(options) + { + private readonly IPostQuantumProtector _protector = protector; + + public DbSet Records => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(b => + { + b.HasKey(r => r.Id); + b.Property(r => r.Email).IsEncrypted(_protector); + b.Property(r => r.Scan).IsEncrypted(_protector); + }); + } + } + + private sealed class SweepContext(DbContextOptions options, IPostQuantumProtector protector) + : RecordContextBase(options, protector); + + private sealed class MarkContext(DbContextOptions options, IPostQuantumProtector protector) + : RecordContextBase(options, protector); + + private static (IPostQuantumProtector Protector, InMemoryDataProtectionKeyRing Ring) NewAes(string activeKeyId) + { + DataEncryptionKey key = DataEncryptionKey.Generate(activeKeyId); + var ring = new InMemoryDataProtectionKeyRing(key); + var protector = new PostQuantumProtector([new Aes256GcmSchemeHandler(ring)], EncryptionScheme.Aes256Gcm); + return (protector, ring); + } + + private static string EmailKeyId(SqliteConnection connection, int id) + { + using SqliteCommand command = connection.CreateCommand(); + command.CommandText = "SELECT Email FROM Records WHERE Id = $id"; + command.Parameters.AddWithValue("$id", id); + var bytes = (byte[])command.ExecuteScalar()!; + return EncryptedEnvelope.Parse(bytes).KeyId; + } + + [Fact] + public async Task ReEncryptAsync_rewrites_every_row_under_the_new_key() + { + using var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + (IPostQuantumProtector protector, InMemoryDataProtectionKeyRing ring) = NewAes("dek-A"); + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + using (var ctx = new SweepContext(options, protector)) + { + ctx.Database.EnsureCreated(); + for (int i = 0; i < 25; i++) + { + ctx.Records.Add(new Record { Email = $"user{i}@example.com", Scan = [(byte)i, 1, 2, 3] }); + } + + await ctx.SaveChangesAsync(); + } + + Assert.Equal("dek-A", EmailKeyId(connection, 1)); + + // Rotate in place: add the new key and activate it, then re-encrypt every row. + ring.AddKey(DataEncryptionKey.Generate("dek-B")); + ring.SetActiveKey("dek-B"); + + int count; + using (var ctx = new SweepContext(options, protector)) + { + count = await ctx.ReEncryptAsync(batchSize: 10); + } + + Assert.Equal(25, count); + Assert.Equal("dek-B", EmailKeyId(connection, 1)); + Assert.Equal("dek-B", EmailKeyId(connection, 25)); + + // Key A is no longer referenced by any row: retiring it must not break reads. + Assert.True(ring.RemoveKey("dek-A")); + using (var ctx = new SweepContext(options, protector)) + { + List all = await ctx.Records.OrderBy(r => r.Id).ToListAsync(); + Assert.Equal(25, all.Count); + Assert.Equal("user0@example.com", all[0].Email); + Assert.Equal(new byte[] { 0, 1, 2, 3 }, all[0].Scan); + } + } + + [Fact] + public async Task A_plain_save_does_not_re_encrypt_but_MarkModified_does() + { + using var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + (IPostQuantumProtector protector, InMemoryDataProtectionKeyRing ring) = NewAes("dek-A"); + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + using (var ctx = new MarkContext(options, protector)) + { + ctx.Database.EnsureCreated(); + ctx.Records.Add(new Record { Email = "user@example.com", Scan = [9, 9, 9] }); + await ctx.SaveChangesAsync(); + } + + ring.AddKey(DataEncryptionKey.Generate("dek-B")); + ring.SetActiveKey("dek-B"); + + // Load and SaveChanges without marking: the decrypted value is unchanged, so EF + // generates no UPDATE and the row stays under key A. + using (var ctx = new MarkContext(options, protector)) + { + Record record = await ctx.Records.SingleAsync(); + _ = record.Email; + await ctx.SaveChangesAsync(); + } + + Assert.Equal("dek-A", EmailKeyId(connection, 1)); + + // Now force re-encryption explicitly. + using (var ctx = new MarkContext(options, protector)) + { + Record record = await ctx.Records.SingleAsync(); + int marked = ctx.MarkEncryptedPropertiesModified(record); + Assert.Equal(2, marked); // Email + Scan + await ctx.SaveChangesAsync(); + } + + Assert.Equal("dek-B", EmailKeyId(connection, 1)); + } + + [Fact] + public async Task ReEncryptAsync_returns_zero_when_no_properties_are_encrypted() + { + using var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + using var ctx = new PlainContext(options); + ctx.Database.EnsureCreated(); + ctx.Plain.Add(new Plain { Name = "x" }); + await ctx.SaveChangesAsync(); + + Assert.Equal(0, await ctx.ReEncryptAsync()); + } + + private sealed class Plain + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + + private sealed class PlainContext(DbContextOptions options) : DbContext(options) + { + public DbSet Plain => Set(); + } +} From 5e44558275ba66cc7176855241c709a8d5e9a6b1 Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Wed, 1 Jul 2026 05:43:52 -0400 Subject: [PATCH 2/3] Address code review: safe online re-encryption + atomic ring rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes three findings from review (gdm.md): 1. Data loss in online re-encryption. ReEncryptAsync used offset pagination (Skip/Take), which can skip an unswept old-key row when concurrent inserts/ deletes shift rows across a batch boundary — losing that row's plaintext once the old key is dropped. Now snapshots primary keys once and fetches each batch by key membership (IN), which is immune to row shifting. Rows inserted after the snapshot are already under the active key; deleted rows are simply not found. Signature is now ReEncryptAsync() (typed key for the snapshot/IN); validates the key type against the model. 2. TOCTOU race retiring keys. The active-key check and dictionary mutation in RemoveKey were not atomic, and the id->dictionary indirection could let a lock-free ActiveKey read observe a removed key. Rings now hold the active key as a direct volatile reference and serialize AddKey/SetActiveKey/RemoveKey under a lock, so check-then-act is atomic and reads never dangle. 3. ChangeTracker pollution. ReEncryptAsync operated on the caller's context, committing pending changes and evicting the tracked graph. It now requires a dedicated context (no tracked entities), reads AsNoTracking, and clears only its own batch. Tests: +3 (online-sweep verifies every row across batches, dedicated-context guard, key-type mismatch, concurrent rotation stress). 58 pass on net8/net10. Docs and PublicAPI baseline updated for the new signature. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 7 +- KNOWN-GAPS.md | 2 +- README.md | 14 +-- docs/migration.md | 7 +- docs/threat-model.md | 2 +- .../EncryptedDataMaintenance.cs | 85 +++++++++++++------ .../Keys/InMemoryDataProtectionKeyRing.cs | 58 ++++++++----- .../Keys/InMemoryKeyEncapsulationKeyRing.cs | 58 ++++++++----- .../PublicAPI.Shipped.txt | 2 +- .../KeyRingRotationTests.cs | 32 +++++++ .../ReEncryptionTests.cs | 56 ++++++++++-- 11 files changed, 236 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4892a47..58a2dfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,11 +20,14 @@ readable across 1.x. protector to rotate does not work — EF Core caches the model, including the captured protector — so this is the supported path; a KMS-backed ring reflects its active key dynamically.) -- **Re-encryption helpers** (`EncryptedDataMaintenance`): `DbContext.ReEncryptAsync()` +- **Re-encryption helpers** (`EncryptedDataMaintenance`): `DbContext.ReEncryptAsync()` sweeps an entity's rows in batches and rewrites each encrypted column under the active key/scheme; `DbContext.MarkEncryptedPropertiesModified(entity)` does the same for a custom query. These force EF Core to re-run the value converter — a plain load-and-`SaveChanges` - does not, because change tracking compares the unchanged decrypted value. + does not, because change tracking compares the unchanged decrypted value. The sweep + snapshots primary keys up front and batches by key membership (not offset paging), so it is + safe to run online — concurrent inserts/deletes cannot skip a row — and it requires a + dedicated context (no tracked entities) so it never commits or evicts your application's graph. - **Fail-fast startup validation.** Constructing the protector now verifies that the default scheme is usable on this platform and has an active key, so a misconfiguration (for example ML-KEM as the default on a host without it) throws at construction/startup rather than on the diff --git a/KNOWN-GAPS.md b/KNOWN-GAPS.md index 956ba0a..5b4e4db 100644 --- a/KNOWN-GAPS.md +++ b/KNOWN-GAPS.md @@ -57,7 +57,7 @@ production. None of these are secret; several are intentional design choices. interfaces. - **No automatic rotation or re-encryption job.** Rotation is *safe* (old values stay readable by key id) and *supported* — rotate the active key in place with the ring's - `AddKey`/`SetActiveKey`, and re-encrypt existing rows with `DbContext.ReEncryptAsync()` + `AddKey`/`SetActiveKey`, and re-encrypt existing rows with `DbContext.ReEncryptAsync()` (or `MarkEncryptedPropertiesModified` for custom sweeps) — but the library does not *schedule* rotation. You decide when to rotate and when to run the sweep. Note that rebuilding a fresh protector/ring to rotate does **not** work: EF Core caches the model (and the diff --git a/README.md b/README.md index 9e15e3d..98d9399 100644 --- a/README.md +++ b/README.md @@ -223,16 +223,18 @@ on the ring the protector already holds: ```csharp dekRing.AddKey(DataEncryptionKey.Generate("dek-2026-07")); // add the new key dekRing.SetActiveKey("dek-2026-07"); // new writes use it; old rows still decrypt -int rewritten = await db.ReEncryptAsync(); // re-encrypt existing rows under the new key +int rewritten = await db.ReEncryptAsync(); // re-encrypt existing rows under the new key dekRing.RemoveKey("dek-2026-01"); // retire the old key once the sweep is done ``` 1. Add a new key and activate it. New writes use it automatically; existing rows still decrypt by their recorded key id. -2. Re-encrypt old rows with `ReEncryptAsync()` (or `MarkEncryptedPropertiesModified` for a - custom query) to retire a key. A plain load-and-`SaveChanges` will **not** rewrite an - unchanged value — change tracking compares the decrypted value — so the helper marks the - columns for you. +2. Re-encrypt old rows with `ReEncryptAsync()` on a dedicated context (or + `MarkEncryptedPropertiesModified` for a custom query) to retire a key. A plain + load-and-`SaveChanges` will **not** rewrite an unchanged value — change tracking compares + the decrypted value — so the helper marks the columns for you. The sweep snapshots primary + keys up front and batches by key membership, so it is safe to run online (no row is skipped + under concurrent inserts or deletes). 3. Remove the old key from the ring. > **Rotate in place, not by swapping the protector.** EF Core caches the model, and the value @@ -283,7 +285,7 @@ itemized list of current limitations. on the protected value). This is intentional — encryption is non-deterministic. - **No automatic key rotation/scheduling.** The library makes rotation *safe* and provides helpers to perform it (`AddKey`/`SetActiveKey`/`RemoveKey` on the ring and - `ReEncryptAsync()`), but it does not *schedule* it — you decide when. Scheduling belongs + `ReEncryptAsync()`), but it does not *schedule* it — you decide when. Scheduling belongs in PostQuantum.KeyManagement. - **ML-KEM availability is platform-dependent** (see below). Where unavailable, you get a clear `PlatformNotSupportedException` rather than a silent downgrade. AES-256-GCM always diff --git a/docs/migration.md b/docs/migration.md index bcb1d4e..151c0d4 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -81,8 +81,11 @@ generated. Use the helpers, which mark the encrypted columns so EF re-runs the c ```csharp // Sweep every row of an entity in batches, rewriting each encrypted column under the -// now-active key. Safe to run online. -int rewritten = await db.ReEncryptAsync(batchSize: 500); +// now-active key. Run it on a DEDICATED context (no tracked entities): the sweep saves and +// evicts as it goes. It snapshots primary keys up front and batches by key membership, so it +// is safe to run online — concurrent inserts/deletes cannot cause a row to be skipped. +// Pass the entity type and its primary-key type. +int rewritten = await maintenanceDb.ReEncryptAsync(batchSize: 500); // …or, for a custom query / composite keys, force re-encryption per entity: foreach (var c in db.Customers.Where(/* your filter */)) diff --git a/docs/threat-model.md b/docs/threat-model.md index fb76ec3..d26cf60 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -64,7 +64,7 @@ before relying on the library for anything that matters. - Keep keys in a managed store; rotate DEKs on a schedule. Rotate the active key in place on the ring the protector holds, then retire the old key once a re-encryption sweep completes - (see [migration.md](migration.md) and `DbContext.ReEncryptAsync()`). + (see [migration.md](migration.md) and `DbContext.ReEncryptAsync()`). - Prefer the ML-KEM hybrid scheme for new data on supported platforms. - Treat decrypted values as toxic: minimize where they live and how long. - Pad values whose *length* is sensitive before storing them. diff --git a/src/PostQuantum.EntityFrameworkCore/EntityFrameworkCore/EncryptedDataMaintenance.cs b/src/PostQuantum.EntityFrameworkCore/EntityFrameworkCore/EncryptedDataMaintenance.cs index 1835b8d..aeda0f8 100644 --- a/src/PostQuantum.EntityFrameworkCore/EntityFrameworkCore/EncryptedDataMaintenance.cs +++ b/src/PostQuantum.EntityFrameworkCore/EntityFrameworkCore/EncryptedDataMaintenance.cs @@ -86,27 +86,52 @@ private static bool ForceReEncrypt( /// /// Re-encrypts every row of in batches, rewriting each /// encrypted column under the active key and scheme. Safe to run while the application is - /// online; run it after rotating a key (and registering both old and new keys in the - /// ring), then drop the old key once this completes. + /// online; run it after rotating a key (and keeping both old and new keys in the ring), + /// then drop the old key once this completes. /// - /// The context whose model declares the encrypted properties. + /// The entity type to re-encrypt. + /// The CLR type of the entity's single-column primary key. + /// + /// A dedicated context with no tracked entities. The sweep saves and evicts entities + /// as it goes, so sharing a context that holds your application's tracked graph or pending + /// changes is rejected — it would otherwise commit or evict work that is not its own. + /// /// Rows to load, re-encrypt, and save per batch. /// A token to cancel the sweep between batches. /// The total number of rows re-encrypted. /// - /// Requires a single-column primary key for stable paging. For composite keys or custom - /// paging, iterate your own query and call - /// on each entity instead. + /// + /// Concurrency-safe. The primary keys are snapshotted once, up front, and each batch + /// is fetched by exact key membership (IN) — not offset paging — so concurrent + /// inserts and deletes cannot cause a row to be skipped. Rows inserted after the snapshot + /// are already written under the active key and need no re-encryption; rows deleted during + /// the sweep are simply not found. This avoids the data-loss window that offset pagination + /// (Skip/Take) has when the set changes underneath it. + /// + /// + /// Requires a single-column primary key whose type is . For + /// composite keys or custom filtering, iterate your own query and call + /// on each entity instead. + /// /// - public static async Task ReEncryptAsync<[DynamicallyAccessedMembers(EntityMembers)] TEntity>( + public static async Task ReEncryptAsync<[DynamicallyAccessedMembers(EntityMembers)] TEntity, TKey>( this DbContext context, int batchSize = 500, CancellationToken cancellationToken = default) where TEntity : class + where TKey : notnull { ArgumentNullException.ThrowIfNull(context); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(batchSize); + if (context.ChangeTracker.Entries().Any()) + { + throw new InvalidOperationException( + "ReEncryptAsync requires a context with no tracked entities: it saves and evicts " + + "entities as it sweeps, which would commit or discard your application's tracked " + + "graph. Use a dedicated DbContext for the re-encryption sweep."); + } + IEntityType entityType = context.Model.FindEntityType(typeof(TEntity)) ?? throw new ArgumentException( $"'{typeof(TEntity)}' is not part of this context's model.", nameof(context)); @@ -128,24 +153,39 @@ private static bool ForceReEncrypt( "support. Iterate your own ordered query and call MarkEncryptedPropertiesModified."); } - string keyName = primaryKey.Properties[0].Name; + IProperty keyProperty = primaryKey.Properties[0]; + if (keyProperty.ClrType != typeof(TKey)) + { + throw new ArgumentException( + $"The primary key of '{typeof(TEntity)}' is '{keyProperty.ClrType}', but TKey is " + + $"'{typeof(TKey)}'. Call ReEncryptAsync<{typeof(TEntity).Name}, {keyProperty.ClrType.Name}>()."); + } + + string keyName = keyProperty.Name; + + // Snapshot the keys once. Batching by key membership (below) is immune to the row + // shifting that makes Skip/Take unsafe under concurrent inserts and deletes. + List keys = await context.Set() + .AsNoTracking() + .OrderBy(e => EF.Property(e, keyName)) + .Select(e => EF.Property(e, keyName)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + int total = 0; - for (int skip = 0; ; skip += batchSize) + for (int offset = 0; offset < keys.Count; offset += batchSize) { + List slice = keys.GetRange(offset, Math.Min(batchSize, keys.Count - offset)); + List batch = await context.Set() - .OrderBy(e => EF.Property(e, keyName)) - .Skip(skip) - .Take(batchSize) + .AsNoTracking() + .Where(e => slice.Contains(EF.Property(e, keyName))) .ToListAsync(cancellationToken) .ConfigureAwait(false); - if (batch.Count == 0) - { - break; - } - foreach (TEntity entity in batch) { + context.Attach(entity); Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry entry = context.Entry(entity); foreach (string name in encrypted) { @@ -153,14 +193,11 @@ private static bool ForceReEncrypt( } } - await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - total += batch.Count; + total += await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - // Detach the batch so the change tracker does not grow across a large sweep. - foreach (TEntity entity in batch) - { - context.Entry(entity).State = EntityState.Detached; - } + // Only this batch is tracked (the context started empty), so clearing is safe and + // keeps the change tracker from growing across a large sweep. + context.ChangeTracker.Clear(); } return total; diff --git a/src/PostQuantum.EntityFrameworkCore/Keys/InMemoryDataProtectionKeyRing.cs b/src/PostQuantum.EntityFrameworkCore/Keys/InMemoryDataProtectionKeyRing.cs index 8b36e9a..e5e0138 100644 --- a/src/PostQuantum.EntityFrameworkCore/Keys/InMemoryDataProtectionKeyRing.cs +++ b/src/PostQuantum.EntityFrameworkCore/Keys/InMemoryDataProtectionKeyRing.cs @@ -26,7 +26,12 @@ namespace PostQuantum.EntityFrameworkCore.Keys; public sealed class InMemoryDataProtectionKeyRing : IDataProtectionKeyRing, IDisposable { private readonly ConcurrentDictionary _keys; - private volatile string _activeKeyId; + private readonly object _rotationLock = new(); + + // The active key is held as a direct reference (not an id that is looked up separately), + // so a lock-free read of ActiveKey can never observe an id whose key was concurrently + // removed. All mutations are serialized by _rotationLock so check-then-act is atomic. + private volatile DataEncryptionKey _activeKey = null!; private bool _disposed; /// @@ -48,13 +53,13 @@ public InMemoryDataProtectionKeyRing(string activeKeyId, IEnumerableCreates a ring holding a single key, which is also the active key. @@ -69,7 +74,7 @@ public DataEncryptionKey ActiveKey get { ObjectDisposedException.ThrowIf(_disposed, this); - return _keys[_activeKeyId]; + return _activeKey; } } @@ -89,11 +94,14 @@ public DataEncryptionKey ActiveKey /// A key with the same id is already present. public void AddKey(DataEncryptionKey key) { - ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(key); - if (!_keys.TryAdd(key.KeyId, key)) + lock (_rotationLock) { - throw new ArgumentException($"A key with id '{key.KeyId}' is already in the ring.", nameof(key)); + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_keys.TryAdd(key.KeyId, key)) + { + throw new ArgumentException($"A key with id '{key.KeyId}' is already in the ring.", nameof(key)); + } } } @@ -105,14 +113,17 @@ public void AddKey(DataEncryptionKey key) /// No key with this id is in the ring. public void SetActiveKey(string keyId) { - ObjectDisposedException.ThrowIf(_disposed, this); ArgumentException.ThrowIfNullOrWhiteSpace(keyId); - if (!_keys.ContainsKey(keyId)) + lock (_rotationLock) { - throw new ArgumentException($"No key with id '{keyId}' is in the ring; add it first.", nameof(keyId)); - } + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_keys.TryGetValue(keyId, out DataEncryptionKey? key)) + { + throw new ArgumentException($"No key with id '{keyId}' is in the ring; add it first.", nameof(keyId)); + } - _activeKeyId = keyId; + _activeKey = key; + } } /// @@ -124,20 +135,23 @@ public void SetActiveKey(string keyId) /// An attempt was made to remove the active key. public bool RemoveKey(string keyId) { - ObjectDisposedException.ThrowIf(_disposed, this); ArgumentException.ThrowIfNullOrWhiteSpace(keyId); - if (string.Equals(keyId, _activeKeyId, StringComparison.Ordinal)) + lock (_rotationLock) { - throw new ArgumentException("Cannot remove the active key; activate another key first.", nameof(keyId)); - } + ObjectDisposedException.ThrowIf(_disposed, this); + if (string.Equals(keyId, _activeKey.KeyId, StringComparison.Ordinal)) + { + throw new ArgumentException("Cannot remove the active key; activate another key first.", nameof(keyId)); + } - if (_keys.TryRemove(keyId, out DataEncryptionKey? removed)) - { - removed.Dispose(); - return true; - } + if (_keys.TryRemove(keyId, out DataEncryptionKey? removed)) + { + removed.Dispose(); + return true; + } - return false; + return false; + } } /// Zeroes and disposes every key held by the ring. diff --git a/src/PostQuantum.EntityFrameworkCore/Keys/InMemoryKeyEncapsulationKeyRing.cs b/src/PostQuantum.EntityFrameworkCore/Keys/InMemoryKeyEncapsulationKeyRing.cs index 7325a75..a608e3d 100644 --- a/src/PostQuantum.EntityFrameworkCore/Keys/InMemoryKeyEncapsulationKeyRing.cs +++ b/src/PostQuantum.EntityFrameworkCore/Keys/InMemoryKeyEncapsulationKeyRing.cs @@ -23,7 +23,12 @@ namespace PostQuantum.EntityFrameworkCore.Keys; public sealed class InMemoryKeyEncapsulationKeyRing : IKeyEncapsulationKeyRing, IDisposable { private readonly ConcurrentDictionary _keys; - private volatile string _activeKeyId; + private readonly object _rotationLock = new(); + + // Held as a direct reference (not an id looked up separately) so a lock-free read of + // ActiveKey cannot observe an id whose pair was concurrently removed. Mutations are + // serialized by _rotationLock so check-then-act is atomic. + private volatile KeyEncapsulationKeyPair _activeKey = null!; private bool _disposed; /// Creates a ring from a set of key pairs, designating one as active. @@ -41,13 +46,13 @@ public InMemoryKeyEncapsulationKeyRing(string activeKeyId, IEnumerableCreates a ring holding a single key pair, which is also active. @@ -62,7 +67,7 @@ public KeyEncapsulationKeyPair ActiveKey get { ObjectDisposedException.ThrowIf(_disposed, this); - return _keys[_activeKeyId]; + return _activeKey; } } @@ -78,11 +83,14 @@ public KeyEncapsulationKeyPair ActiveKey /// A pair with the same id is already present. public void AddKey(KeyEncapsulationKeyPair key) { - ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(key); - if (!_keys.TryAdd(key.KeyId, key)) + lock (_rotationLock) { - throw new ArgumentException($"A key with id '{key.KeyId}' is already in the ring.", nameof(key)); + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_keys.TryAdd(key.KeyId, key)) + { + throw new ArgumentException($"A key with id '{key.KeyId}' is already in the ring.", nameof(key)); + } } } @@ -90,14 +98,17 @@ public void AddKey(KeyEncapsulationKeyPair key) /// No pair with this id is in the ring. public void SetActiveKey(string keyId) { - ObjectDisposedException.ThrowIf(_disposed, this); ArgumentException.ThrowIfNullOrWhiteSpace(keyId); - if (!_keys.ContainsKey(keyId)) + lock (_rotationLock) { - throw new ArgumentException($"No key with id '{keyId}' is in the ring; add it first.", nameof(keyId)); - } + ObjectDisposedException.ThrowIf(_disposed, this); + if (!_keys.TryGetValue(keyId, out KeyEncapsulationKeyPair? key)) + { + throw new ArgumentException($"No key with id '{keyId}' is in the ring; add it first.", nameof(keyId)); + } - _activeKeyId = keyId; + _activeKey = key; + } } /// @@ -108,20 +119,23 @@ public void SetActiveKey(string keyId) /// An attempt was made to remove the active key. public bool RemoveKey(string keyId) { - ObjectDisposedException.ThrowIf(_disposed, this); ArgumentException.ThrowIfNullOrWhiteSpace(keyId); - if (string.Equals(keyId, _activeKeyId, StringComparison.Ordinal)) + lock (_rotationLock) { - throw new ArgumentException("Cannot remove the active key; activate another key first.", nameof(keyId)); - } + ObjectDisposedException.ThrowIf(_disposed, this); + if (string.Equals(keyId, _activeKey.KeyId, StringComparison.Ordinal)) + { + throw new ArgumentException("Cannot remove the active key; activate another key first.", nameof(keyId)); + } - if (_keys.TryRemove(keyId, out KeyEncapsulationKeyPair? removed)) - { - removed.Dispose(); - return true; - } + if (_keys.TryRemove(keyId, out KeyEncapsulationKeyPair? removed)) + { + removed.Dispose(); + return true; + } - return false; + return false; + } } /// Zeroes and disposes every key pair held by the ring. diff --git a/src/PostQuantum.EntityFrameworkCore/PublicAPI.Shipped.txt b/src/PostQuantum.EntityFrameworkCore/PublicAPI.Shipped.txt index 7516c61..f778cca 100644 --- a/src/PostQuantum.EntityFrameworkCore/PublicAPI.Shipped.txt +++ b/src/PostQuantum.EntityFrameworkCore/PublicAPI.Shipped.txt @@ -88,7 +88,7 @@ PostQuantum.EntityFrameworkCore.PostQuantumProtector.UnprotectText(System.ReadOn PostQuantum.EntityFrameworkCore.PostQuantumProtectorExtensions static PostQuantum.EntityFrameworkCore.DependencyInjection.ServiceCollectionExtensions.AddPostQuantumEncryption(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static PostQuantum.EntityFrameworkCore.EntityFrameworkCore.EncryptedDataMaintenance.MarkEncryptedPropertiesModified(this Microsoft.EntityFrameworkCore.DbContext! context, TEntity! entity) -> int -static PostQuantum.EntityFrameworkCore.EntityFrameworkCore.EncryptedDataMaintenance.ReEncryptAsync(this Microsoft.EntityFrameworkCore.DbContext! context, int batchSize = 500, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static PostQuantum.EntityFrameworkCore.EntityFrameworkCore.EncryptedDataMaintenance.ReEncryptAsync(this Microsoft.EntityFrameworkCore.DbContext! context, int batchSize = 500, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! static PostQuantum.EntityFrameworkCore.EntityFrameworkCore.EncryptedPropertyBuilderExtensions.IsEncrypted(this Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder! propertyBuilder, PostQuantum.EntityFrameworkCore.IPostQuantumProtector! protector) -> Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder! static PostQuantum.EntityFrameworkCore.EntityFrameworkCore.EncryptedPropertyBuilderExtensions.IsEncrypted(this Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder! propertyBuilder, PostQuantum.EntityFrameworkCore.IPostQuantumProtector! protector) -> Microsoft.EntityFrameworkCore.Metadata.Builders.PropertyBuilder! static PostQuantum.EntityFrameworkCore.Keys.DataEncryptionKey.Generate(string! keyId) -> PostQuantum.EntityFrameworkCore.Keys.DataEncryptionKey! diff --git a/tests/PostQuantum.EntityFrameworkCore.Tests/KeyRingRotationTests.cs b/tests/PostQuantum.EntityFrameworkCore.Tests/KeyRingRotationTests.cs index 00f3b45..abb2196 100644 --- a/tests/PostQuantum.EntityFrameworkCore.Tests/KeyRingRotationTests.cs +++ b/tests/PostQuantum.EntityFrameworkCore.Tests/KeyRingRotationTests.cs @@ -50,6 +50,38 @@ public void RemoveKey_retires_a_non_active_key_but_not_the_active_one() Assert.Throws(() => ring.RemoveKey("dek-B")); // active key protected } + [Fact] + public void Concurrent_rotation_and_reads_never_leave_the_active_key_dangling() + { + // Stresses the rotation lock: interleaving AddKey/SetActiveKey/RemoveKey with ActiveKey + // reads must never throw (e.g. remove the active key out from under a reader). + using var ring = new InMemoryDataProtectionKeyRing(DataEncryptionKey.Generate("dek-0")); + var failures = new System.Collections.Concurrent.ConcurrentBag(); + + Parallel.For(1, 200, i => + { + string id = $"dek-{i}"; + try + { + ring.AddKey(DataEncryptionKey.Generate(id)); + ring.SetActiveKey(id); + _ = ring.ActiveKey.KeyId; // must always resolve to a present key + ring.RemoveKey("dek-0"); // races with everyone; false or throws-if-active is fine + } + catch (ArgumentException) + { + // Expected, benign contention: e.g. dek-0 became active, or was already removed. + } + catch (Exception ex) + { + failures.Add($"{id}: {ex.GetType().Name}"); + } + }); + + Assert.Empty(failures); + Assert.NotNull(ring.ActiveKey); // invariant holds after the storm + } + [Fact] public void Kek_ring_supports_the_same_rotation_surface() { diff --git a/tests/PostQuantum.EntityFrameworkCore.Tests/ReEncryptionTests.cs b/tests/PostQuantum.EntityFrameworkCore.Tests/ReEncryptionTests.cs index bab9449..0845823 100644 --- a/tests/PostQuantum.EntityFrameworkCore.Tests/ReEncryptionTests.cs +++ b/tests/PostQuantum.EntityFrameworkCore.Tests/ReEncryptionTests.cs @@ -97,12 +97,15 @@ public async Task ReEncryptAsync_rewrites_every_row_under_the_new_key() int count; using (var ctx = new SweepContext(options, protector)) { - count = await ctx.ReEncryptAsync(batchSize: 10); + count = await ctx.ReEncryptAsync(batchSize: 10); } Assert.Equal(25, count); - Assert.Equal("dek-B", EmailKeyId(connection, 1)); - Assert.Equal("dek-B", EmailKeyId(connection, 25)); + // Every row — across all three batches — must now be under the new key (no row skipped). + for (int id = 1; id <= 25; id++) + { + Assert.Equal("dek-B", EmailKeyId(connection, id)); + } // Key A is no longer referenced by any row: retiring it must not break reads. Assert.True(ring.RemoveKey("dek-A")); @@ -167,12 +170,53 @@ public async Task ReEncryptAsync_returns_zero_when_no_properties_are_encrypted() .UseSqlite(connection) .Options; + using (var seed = new PlainContext(options)) + { + seed.Database.EnsureCreated(); + seed.Plain.Add(new Plain { Name = "x" }); + await seed.SaveChangesAsync(); + } + using var ctx = new PlainContext(options); + Assert.Equal(0, await ctx.ReEncryptAsync()); + } + + [Fact] + public async Task ReEncryptAsync_rejects_a_context_that_already_tracks_entities() + { + using var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + (IPostQuantumProtector protector, _) = NewAes("dek-A"); + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + using var ctx = new SweepContext(options, protector); ctx.Database.EnsureCreated(); - ctx.Plain.Add(new Plain { Name = "x" }); - await ctx.SaveChangesAsync(); + ctx.Records.Add(new Record { Email = "a@b.c", Scan = [1] }); + await ctx.SaveChangesAsync(); // the added entity stays tracked + + await Assert.ThrowsAsync(() => ctx.ReEncryptAsync()); + } + + [Fact] + public async Task ReEncryptAsync_reports_a_key_type_mismatch() + { + using var connection = new SqliteConnection("DataSource=:memory:"); + connection.Open(); + (IPostQuantumProtector protector, _) = NewAes("dek-A"); + DbContextOptions options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options; + + using (var seed = new SweepContext(options, protector)) + { + seed.Database.EnsureCreated(); + } - Assert.Equal(0, await ctx.ReEncryptAsync()); + using var ctx = new SweepContext(options, protector); + // The primary key is int, not Guid. + await Assert.ThrowsAsync(() => ctx.ReEncryptAsync()); } private sealed class Plain From eac03869577d742ad75866760e5d2ea09ce2647e Mon Sep 17 00:00:00 2001 From: Paul Clark Date: Wed, 1 Jul 2026 06:28:55 -0400 Subject: [PATCH 3/3] ci: publish to NuGet via Trusted Publishing (OIDC) Replace the long-lived NUGET_API_KEY secret with OIDC-based Trusted Publishing: grant the publish job id-token: write and exchange the GitHub OIDC token for a short-lived key via NuGet/login before dotnet nuget push. The secret can be removed from the repo once this ships. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/publish.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7a5f0be..eb81ed3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,6 +13,9 @@ jobs: publish: name: Pack & publish to NuGet runs-on: ubuntu-latest + permissions: + id-token: write # required for NuGet Trusted Publishing (OIDC) + contents: read steps: - uses: actions/checkout@v4 @@ -36,11 +39,15 @@ jobs: - name: Pack (deterministic, with symbols) run: dotnet pack src/PostQuantum.EntityFrameworkCore/PostQuantum.EntityFrameworkCore.csproj -c Release -o artifacts + - name: NuGet login (Trusted Publishing via OIDC) + uses: NuGet/login@v1 + id: nuget-login + with: + user: systemslibrarian + - name: Push to NuGet (package + symbols) - env: - NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }} run: | dotnet nuget push "artifacts/*.nupkg" \ - --api-key "$NUGET_API_KEY" \ + --api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}" \ --source https://api.nuget.org/v3/index.json \ --skip-duplicate