Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
74 changes: 60 additions & 14 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TEntity, TKey>()`
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

Expand Down Expand Up @@ -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
14 changes: 14 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<!-- Build-time only: SourceLink for source-debuggable, verifiable packages. -->
<ItemGroup>
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="10.0.300" />
<PackageVersion Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="4.14.0" />
</ItemGroup>

<!-- Microsoft.Extensions.* abstractions used by the DI integration. -->
Expand Down Expand Up @@ -49,4 +50,17 @@
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
</ItemGroup>

<!--
Transitive pin of the SQLitePCLRaw native SQLite bundle used by the EF Core SQLite
provider in tests and the sample. The 2.1.x series carries GHSA-2m69-gcr7-jv3q; the
3.x line ships a patched SQLite. These are test/sample-only and are NOT part of the
shipped library package. Pinned centrally so every project resolves the patched build.
-->
<ItemGroup>
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
<PackageVersion Include="SQLitePCLRaw.core" Version="3.0.3" />
<PackageVersion Include="SQLitePCLRaw.provider.e_sqlite3" Version="3.0.3" />
<PackageVersion Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.50.3" />
</ItemGroup>

</Project>
34 changes: 20 additions & 14 deletions KNOWN-GAPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<TEntity, TKey>()`
(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.
Expand Down
79 changes: 67 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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? |
| --- | --- | --- | --- |
Expand Down Expand Up @@ -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<Customer, int>(); // 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<TEntity, TKey>()` 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

Expand Down Expand Up @@ -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<TEntity, TKey>()`), 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.
Expand Down Expand Up @@ -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

Expand Down
Loading