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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ed5fd2..58a2dfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,23 +6,68 @@ 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. 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 + 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 +111,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..5b4e4db 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..98d9399 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,34 @@ 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: + +```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 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. +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()` 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 +> 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 +283,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 +356,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..151c0d4 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -58,14 +58,49 @@ 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. 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 */)) + 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..d26cf60 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..aeda0f8 --- /dev/null +++ b/src/PostQuantum.EntityFrameworkCore/EntityFrameworkCore/EncryptedDataMaintenance.cs @@ -0,0 +1,222 @@ +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 keeping both old and new keys in the ring), + /// then drop the old key once this completes. + /// + /// 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. + /// + /// + /// 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, 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)); + + 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."); + } + + 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 offset = 0; offset < keys.Count; offset += batchSize) + { + List slice = keys.GetRange(offset, Math.Min(batchSize, keys.Count - offset)); + + List batch = await context.Set() + .AsNoTracking() + .Where(e => slice.Contains(EF.Property(e, keyName))) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + foreach (TEntity entity in batch) + { + context.Attach(entity); + Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry entry = context.Entry(entity); + foreach (string name in encrypted) + { + ForceReEncrypt(entry.Property(name)); + } + } + + total += await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + + // 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; + } + + 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..e5e0138 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,24 @@ 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 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; /// @@ -29,7 +44,7 @@ public InMemoryDataProtectionKeyRing(string activeKeyId, IEnumerable(StringComparer.Ordinal); + _keys = new ConcurrentDictionary(StringComparer.Ordinal); foreach (DataEncryptionKey key in keys) { if (!_keys.TryAdd(key.KeyId, key)) @@ -38,13 +53,13 @@ public InMemoryDataProtectionKeyRing(string activeKeyId, IEnumerableCreates a ring holding a single key, which is also the active key. @@ -59,7 +74,7 @@ public DataEncryptionKey ActiveKey get { ObjectDisposedException.ThrowIf(_disposed, this); - return _keys[_activeKeyId]; + return _activeKey; } } @@ -71,6 +86,74 @@ 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) + { + ArgumentNullException.ThrowIfNull(key); + lock (_rotationLock) + { + 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)); + } + } + } + + /// + /// 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) + { + ArgumentException.ThrowIfNullOrWhiteSpace(keyId); + lock (_rotationLock) + { + 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)); + } + + _activeKey = key; + } + } + + /// + /// 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) + { + ArgumentException.ThrowIfNullOrWhiteSpace(keyId); + lock (_rotationLock) + { + 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; + } + + 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..a608e3d 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,28 @@ 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 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. @@ -22,7 +37,7 @@ public InMemoryKeyEncapsulationKeyRing(string activeKeyId, IEnumerable(StringComparer.Ordinal); + _keys = new ConcurrentDictionary(StringComparer.Ordinal); foreach (KeyEncapsulationKeyPair key in keys) { if (!_keys.TryAdd(key.KeyId, key)) @@ -31,13 +46,13 @@ public InMemoryKeyEncapsulationKeyRing(string activeKeyId, IEnumerableCreates a ring holding a single key pair, which is also active. @@ -52,7 +67,7 @@ public KeyEncapsulationKeyPair ActiveKey get { ObjectDisposedException.ThrowIf(_disposed, this); - return _keys[_activeKeyId]; + return _activeKey; } } @@ -64,6 +79,65 @@ 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) + { + ArgumentNullException.ThrowIfNull(key); + lock (_rotationLock) + { + 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)); + } + } + } + + /// 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) + { + ArgumentException.ThrowIfNullOrWhiteSpace(keyId); + lock (_rotationLock) + { + 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)); + } + + _activeKey = key; + } + } + + /// + /// 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) + { + ArgumentException.ThrowIfNullOrWhiteSpace(keyId); + lock (_rotationLock) + { + 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; + } + + 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..f778cca --- /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..abb2196 --- /dev/null +++ b/tests/PostQuantum.EntityFrameworkCore.Tests/KeyRingRotationTests.cs @@ -0,0 +1,98 @@ +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 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() + { + 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..0845823 --- /dev/null +++ b/tests/PostQuantum.EntityFrameworkCore.Tests/ReEncryptionTests.cs @@ -0,0 +1,232 @@ +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); + // 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")); + 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 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.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(); + } + + using var ctx = new SweepContext(options, protector); + // The primary key is int, not Guid. + await Assert.ThrowsAsync(() => 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(); + } +}