Skip to content

feat(crypto): add per-page HPKE encryption for LTX v4 - #78

Open
corylanou wants to merge 10 commits into
mainfrom
issue-77-proposal-per-page-encryption-using-hpke-rfc-9180
Open

feat(crypto): add per-page HPKE encryption for LTX v4#78
corylanou wants to merge 10 commits into
mainfrom
issue-77-proposal-per-page-encryption-using-hpke-rfc-9180

Conversation

@corylanou

@corylanou corylanou commented Feb 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds per-page encryption to the LTX file format (v4) using HPKE (RFC 9180) with DHKEM(X25519) + HKDF-SHA256 + ChaCha20-Poly1305.

  • Each file gets a random 32-byte CEK wrapped per-recipient via HPKE, with HKDF-derived keys for page encryption and index authentication
  • Duplicate recipient block at start and end of file enables both streaming decryption and efficient VFS random-access reads
  • Multi-recipient support from day one for key rotation scenarios
  • New CLI commands: keygen (generate X25519 keypair), rekey (re-encrypt with new recipients)
  • Encryption flags on existing commands: -key for decryption, -encrypt-to for encryption
  • Backward compatible: v3 files still decode correctly with v4 code

Fixes #77

Changes since the original review

Rebased onto main (twice — once for #80, once after #89, #90, #91 and #92 merged) and audited end to end. Four things came out of that:

Unencrypted output stays at v3. encode-db and the compactor were emitting the new LTX4 magic for every file, encrypted or not, which every existing reader rejects — including litestream. An unencrypted v4 header is byte-identical to a v3 header, since the new fields occupy v3 reserved space and are zero, so this broke readers without changing the file. The encoder now emits the lowest version that represents the requested features, and LTX4 in practice means encrypted. TestEncoder_FormatVersionSelection pins both directions.

Integrity is now verifiable without a key. Encrypted files hash their ciphertext into the file checksum specifically so a holder of the file can detect corruption without holding a decryption key, but DecodeHeader rejected any encrypted file when no key was supplied, making the capability unreachable. Decoding without a key now verifies structure and the file checksum, skipping decryption, the index auth tag, and the plaintext post-apply comparison. DecodePage still refuses, so nothing can mistake an unread buffer for page contents, and ltx verify reports the narrower guarantee instead of a bare ok.

Key derivation moved to crypto/hkdf. The module already requires Go 1.26 for crypto/hpke, so the x/crypto import was redundant. Verified byte-identical output for both derivation labels before switching, so existing encrypted files still decrypt. chacha20poly1305 has no stdlib equivalent and stays.

Two crypto invariants are now documented. AuthenticateIndex uses a fixed nonce and EncryptPage uses random 96-bit nonces. Both are safe only because a CEK is generated fresh per file and never reused; reusing one across files would leak the Poly1305 key and permit index forgery. Nothing recorded that previously.

Also: the format documentation rewritten in #92 states that LTX carries no on-disk version field and that a reader cannot determine the version from the file. v4 changes the magic, so both became wrong here — the README now covers the LTX1/LTX4 split, the v4 header fields, the recipient block, encrypted frame layout and AAD binding, the index auth tag, and the ciphertext checksum domain.

Compatibility note for reviewers

This branch raises the Go floor to 1.26, because crypto/hpke does not exist before it. go.mod, the Dockerfile and both workflows move from 1.24 to 1.26. That is a larger consumer-facing change than the encryption feature itself, and it conflicts with #64, which adds a CI matrix covering Go 1.22–1.24. Those two cannot both land unchanged.

Test Plan

  • Crypto primitive tests: key generation, CEK seal/open (single/multi-recipient/wrong key), page encrypt/decrypt (round-trip/tampered/wrong AAD), index auth (round-trip/tampered), key derivation
  • Keyless decoding: integrity verification succeeds, page access refused, bit-flipped ciphertext still detected
  • Format version selection: unencrypted emits LTX1/v3, encrypted emits LTX4/v4
  • Encoder/decoder round-trip with encryption; multi-recipient decryption
  • Compactor: encrypted→encrypted, encrypted→unencrypted, unencrypted→encrypted
  • All existing v3 tests continue to pass
  • New E2E CLI suite (11 groups): keygen, single and multi-recipient, rekey, sequential encrypted apply, backward compatibility, tamper detection, error paths, truncation at six offsets, page sizes 512B–64KB. Previously an uncommitted script run by hand on macOS; now committed and run on linux in CI.
  • go build ./..., go vet ./..., go test ./..., go test -race ./... all pass

@corylanou

Copy link
Copy Markdown
Collaborator Author

LTX HPKE E2E Encryption Test Report

  • Date: 2026-02-25T19:21:33Z
  • Git commit: 5f0c59d
  • Go version: go version go1.26.0 darwin/arm64
  • LTX version: ltx development build
  • Platform: Darwin/arm64

Results

Test Result Duration
Test 1: Docker Build PASS 4.539s
Test 2: Key Generation PASS 58ms
Test 3: Single-Key Encryption Workflow PASS 76ms
Test 4: Multi-Recipient PASS 118ms
Test 5: Key Rotation (Rekey) PASS 110ms
Test 6: Sequential Encrypted Apply PASS 52ms
Test 7: Backward Compatibility PASS 75ms
Test 8: Tamper Detection PASS 70ms
Test 9: Error Paths PASS 64ms
Test 10: Truncation Simulation PASS 96ms
Test 11: Page Sizes (512B–64KB) PASS 459ms

Summary

  • Total: 11
  • Passed: 11
  • Failed: 0

@corylanou
corylanou force-pushed the issue-77-proposal-per-page-encryption-using-hpke-rfc-9180 branch from 5f0c59d to 1f00b0e Compare August 12, 2026 16:19
Implement per-page encryption using HPKE (RFC 9180) with
DHKEM(X25519) + HKDF-SHA256 + ChaCha20-Poly1305. Each LTX file
gets a random CEK wrapped per-recipient via HPKE, with separate
derived keys for page encryption and index authentication.

Key changes:
- Bump format to v4 with backward-compatible v3 decoding
- Add HeaderFlagEncryptedHPKE flag and encryption header fields
- Per-page ChaCha20-Poly1305 encryption with random nonces
- Page index authentication via AEAD with empty plaintext
- Duplicate recipient block at start and end for VFS access
- Multi-recipient support from day one
- CLI: keygen, rekey commands; -key/-encrypt-to flags on
  apply, dump, encode-db, list, verify

Refs #77
Required for crypto/hpke stdlib package used by HPKE encryption.
…ncryption on empty recipients

- Decoder now rejects pages without the size flag when the file is
  encrypted, preventing bypass of per-page AEAD verification
- FileSpec.WriteTo clears encryption header fields when no recipient
  keys are configured, preventing corrupt output from rekey without
  -encrypt-to
The 4.2MB dist/ltx binary was committed to the repo, and the E2E script
builds into that same path, so running the tests dirtied the working tree.
Ignore dist/ and the stray test_hpke binary instead.
crypto/hkdf landed in Go 1.24 and this module already requires 1.26 for
crypto/hpke, so the x/crypto import is no longer needed for key derivation.
Verified byte-identical output for both derivation labels before switching,
so existing encrypted files still decrypt.

Also record the invariant both AEAD constructions rely on: AuthenticateIndex
uses a fixed nonce and EncryptPage uses random 96-bit nonces, and each is
safe only because a CEK is generated per file and never reused. Reusing a
CEK across files would leak the Poly1305 key and permit index forgery.
Nothing said so previously.
Two problems found auditing the v4 encryption work.

Unencrypted files were being written with the new LTX4 magic. Encryption is
the only v4 feature, and an unencrypted v4 header is byte-identical to a v3
header (the new fields occupy v3 reserved space and are zero), so this broke
every existing reader without changing the file. encode-db and the compactor
both hit it because they pass ltx.Version unconditionally. The encoder now
emits the lowest version that represents the requested features.

Encrypted files hash their ciphertext into the file checksum specifically so
integrity can be checked without a key, but DecodeHeader rejected any
encrypted file when no key was supplied, so the capability was unreachable.
Decoding without a key now verifies structure and the file checksum while
skipping decryption, the index auth tag, and the plaintext post-apply
comparison. DecodePage still refuses, so no caller can mistake an unread
buffer for page contents, and ltx verify reports the reduced scope rather
than printing a bare ok.
Eleven groups covering key generation, single and multi-recipient
encryption, rekeying, sequential encrypted apply, backward compatibility
with unencrypted files, tamper detection, error paths, truncation at
several offsets, and page sizes from 512B to 64KB.

This existed as an uncommitted file in the worktree and was the only thing
exercising the CLI end to end.
The command test arrived with #90 while this branch was in flight. On main
ltx.Version is 3 so it passed; here encryption raises the constant to 4
while unencrypted output correctly stays v3.
@corylanou
corylanou force-pushed the issue-77-proposal-per-page-encryption-using-hpke-rfc-9180 branch from 1f00b0e to 895f80f Compare August 12, 2026 16:22
The README rewritten in #92 says LTX carries no on-disk version field and a
reader cannot determine the version from the file. Version 4 changes the
magic to LTX4, so both statements became wrong on this branch, and nothing
described encryption at all.

Covers the LTX1/LTX4 split and why unencrypted output stays v3, the v4
header fields and encryption flag, the recipient block and key derivation,
encrypted page frame layout and AAD binding, the page index auth tag, and
the fact that encrypted files hash ciphertext so integrity is verifiable
without a key.
The E2E suite is the only thing exercising the CLI end to end, and it had
only ever been run by hand on macOS while the project ships on linux. Run it
as its own job so shell and tooling assumptions are caught automatically.

sqlite3 is installed explicitly; python3, dd and docker are already present
on the runner.
@corylanou

Copy link
Copy Markdown
Collaborator Author

Data on the Go 1.26 floor, since that's the thing gating this.

Downstream state today:

Consumer Go Pinned ltx
litestream 1.25.0 v0.5.1
LiteFS 1.21 v0.3.14

litestream is the consumer that actually tracks this repo — one minor behind on Go, one patch behind on ltx. Its workflows all use go-version-file: go.mod, so bumping its go.mod moves CI with it; there's no version pinned in the workflow files to chase. Moving it 1.25 → 1.26 is a one-line change.

LiteFS is a different story. It's on Go 1.21 and pinned to ltx v0.3.14, five minor versions back. It is not currently tracking this repo at all, so the 1.26 floor doesn't block anything for it today — but whenever it does upgrade, it inherits a 1.21 → 1.26 jump on top of a five-version ltx jump.

Two useful consequences.

First, the floor is cheap for the consumer that's actually current and irrelevant to the one that isn't. That's a much weaker objection than it looked before checking.

Second, and worth noting for #84: LiteFS pinning v0.3.14 means it does not yet have the lock-page fix from #89. I checked, and v0.3.14 does contain both ChecksumPages and ChecksumReader (they landed in 2c8411b, which v0.3.14 includes), so LiteFS is genuinely a consumer of the changed functions — it just hasn't picked up the change. Since LiteFS already zeroes the lock-page slot on its own side, it stays correct either way, and gets the fix when it upgrades.

On #64. It adds a CI matrix for Go 1.22–1.24, which cannot coexist with a 1.26 floor. Separately, #64 has gone badly stale — see my note there. If this PR lands, #64's matrix needs rewriting regardless of what happens to the rest of it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proposal: Per-page encryption using HPKE (RFC 9180)

1 participant