Skip to content

feat(kv-store): encryption-at-rest for SQLite-OPFS + PXE opaque-keys (DO NOT MERGE, PROOF OF CONCEPT)#22683

Open
mverzilli wants to merge 5 commits intomerge-train/fairiesfrom
martin/sqlite-with-encryption-at-rest
Open

feat(kv-store): encryption-at-rest for SQLite-OPFS + PXE opaque-keys (DO NOT MERGE, PROOF OF CONCEPT)#22683
mverzilli wants to merge 5 commits intomerge-train/fairiesfrom
martin/sqlite-with-encryption-at-rest

Conversation

@mverzilli
Copy link
Copy Markdown
Contributor

@mverzilli mverzilli commented Apr 21, 2026

Summary

  • Adds value-level encryption to the SQLite-OPFS backend via a pluggable ValueCipher (null-object IdentityCipher for off, AesGcmCipher for on) — disabled by default, bytes-identical to non-encrypted version when no cipher is passed.
  • Introduces opaqueKeys mode for map/multi_map/set containers that need key-level confidentiality (HMAC'd keys + AAD-bound encrypted values; range queries disabled, point lookups and unordered iteration still work).
  • Opts 13 PXE containers into opaqueKeys (notes, nullifier-by-contract, event stores, tagging secrets, address book, wallet accounts/aliases).

What's in the box

kv-store — new cipher.ts module (AES-GCM-256 for values, HMAC-SHA-256 for deterministic digests, HKDF-derived sub-keys, row-binding via AES-GCM AAD = slot). Shared interface gets an optional OpenContainerOptions arg on openMap/openMultiMap/openSet; LMDB/LMDB-v2/IDB accept-and-ignore it silently.

PXE sweepnote_store, private_event_store, address_store, both tagging stores, and the address book flip opaqueKeys: true on 13 audit-flagged containers.

Wallet DB refactor — previous prefix-range pattern (type:ADDR / accounts:ALIAS / senders:ALIAS in two shared maps) is incompatible with HMAC'd keys, so the DB is split into three namespace-per-map containers with opaqueKeys. Public API unchanged; all 17 existing tests pass.

Tests

  • 23 cipher unit tests (encrypt/decrypt/digest/keyDigest + AAD + tamper/wrong-key/wrong-AAD rejections)
  • 19 opaque-keys semantics tests (validation, at-rest observation confirming HMAC'd key column + 0x01-prefixed ciphertext + no plaintext, unordered iteration)
  • Each container's shared suite runs twice — plaintext and encrypted. 171 kv-store tests total.
  • 137 PXE storage tests, 17 wallet_db tests — all green.

Test plan

  • yarn workspace @aztec/kv-store test:browser src/sqlite-opfs/ — 171 passed
  • yarn workspace @aztec/pxe test src/storage/ — 137 passed
  • yarn workspace @aztec/wallets test src/embedded/wallet_db.test.ts — 17 passed
  • yarn build clean on the touched packages
  • yarn lint clean on kv-store, pxe, wallets
  • CI signal on merge-train/fairies

Out of scope / follow-ups

  • IndexedDBKeyProvider — current RawKeyProvider(32 bytes) is for tests / programmatic use. Next up: a provider that persists an unextractable HKDF master to IDB so there's a zero-UX key source for browsers.
  • Cipher wiring for real consumers (gregoswap / PXE entrypoint) — this PR gives consumers the option; it does not flip it on for any real deployment.
  • Key rotation / re-encryption on rotate

🤖 Generated with Claude Code

…opt-ins

Adds value-level encryption to the SQLite-OPFS backend and opts the PXE's
sensitive containers into the new opaque-keys mode. Disabled by default — stores
without a cipher behave exactly as before, and non-SQLite-OPFS backends
(LMDB, IndexedDB) silently ignore the new flag.

kv-store changes
----------------
- New cipher module: `ValueCipher` interface + `IdentityCipher` (null object,
  passthrough) + `AesGcmCipher` (AES-GCM-256 values, HMAC-SHA-256 digests,
  HKDF-derived sub-keys from a master) + `RawKeyProvider` (32-byte seed).
- Values, digests, and optionally keys flow through the store's cipher. The
  map/multi_map/set containers support `opaqueKeys: true`: keys are HMAC'd on
  disk, the encrypted value blob embeds the original key for iteration, and
  range queries with start/end/reverse throw (HMAC destroys ordering).
- AES-GCM AAD = slot binds each ciphertext to its row. Row-rebinding attacks
  (moving a value blob to a different slot via disk write) break the auth tag
  on decrypt. Covered by integration tests.
- Shared interfaces (`AztecKVStore`, `AztecAsyncKVStore`) gain an optional
  `OpenContainerOptions` arg on `openMap`/`openMultiMap`/`openSet`.
- Three backends (IDB, LMDB, LMDB-v2) accept-and-ignore the new option.

PXE opaqueKeys opt-ins
----------------------
Thirteen audit-flagged containers now request opaqueKeys so keys don't leak
on disk when encryption is enabled:

- note_store: notes, note_nullifiers_by_contract, note_block_number_to_nullifier
- private_event_store: private_event_logs, events_by_contract_selector,
  events_by_block_number
- address_store: complete_address_index
- tagging_store: pending_indexes, last_finalized_indexes,
  highest_aged_index, highest_finalized_index, address_book

Wallet DB refactor
------------------
wallet_db previously packed accounts + aliases into two shared maps with
`type:ADDR`, `senders:ALIAS`, `accounts:ALIAS` prefixes and iterated them via
prefix ranges. HMAC'd keys break prefix ranges, so the DB is split into three
namespace-per-map containers (`accounts`, `account_aliases`, `sender_aliases`),
each with opaqueKeys. Public API unchanged; 17 existing tests still pass.

Tests
-----
- 23 cipher unit tests (encrypt/decrypt/digest/keyDigest + AAD round-trip +
  tampering/wrong-key/wrong-AAD rejections)
- 19 opaque-keys semantics tests (validation, at-rest observation proving the
  key column is HMAC'd and the value column starts with 0x01 + no plaintext,
  row-rebinding attack detection, unordered iteration)
- Each container's shared test suite runs twice — once with IdentityCipher,
  once with AesGcmCipher — 171 kv-store tests total.

Format break note
-----------------
Phase-1 SQLite-OPFS DBs stored the multi-map `hash` column as ohash-of-value;
this PR changes it to a bytes-level digest (SHA-256 in identity mode, HMAC in
cipher mode). Pre-existing phase-1 rows will no longer dedup against fresh
inserts of the same (key, value) pair — one extra row instead of dedup. Only
dev DBs are affected in practice.

Security considerations (F1–F7 audit findings) are documented inline at the
top of `cipher.ts`. F1 (row-rebinding) is fixed; F2 (equivalence-class leak),
F3 (no forward secrecy), F4 (KeyProvider entropy discipline), F5 (undetected
deletions), F6 (accepted metadata leaks), and F7 (format break) are documented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mverzilli mverzilli changed the title feat(kv-store): encryption-at-rest for SQLite-OPFS + PXE opaque-keys opt-ins feat(kv-store): encryption-at-rest for SQLite-OPFS + PXE opaque-keys (DO NOT MERGE, PROOF OF CONCEPT) Apr 21, 2026
mverzilli and others added 3 commits April 21, 2026 09:25
…arrel

External consumers (e.g., gregoswap wiring) need AesGcmCipher / RawKeyProvider /
IdentityCipher / ValueCipher / KeyProvider to construct and pass ciphers into
AztecSQLiteOPFSStore.open. Re-export from the package entrypoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…use hazard

msgpackr's Encoder.pack() returns a Uint8Array that is a view into a reusable
internal buffer — subsequent pack calls can overwrite the backing bytes. The
map's set() path fires cipher.encrypt and cipher.digest under Promise.all on
the same pack output; if any concurrent set() runs before those resolve, the
underlying bytes can mutate, leading to silently corrupt encrypted rows.

Copy the view into an owned Uint8Array before handing it to the cipher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before: the SQLite-OPFS store threw at openMap/openSet/openMultiMap time if
opaqueKeys: true was requested on a store without an encryption cipher.

Problem: PXE storage classes hard-code opaqueKeys: true on their sensitive
containers at the source level. The store-level decision of "with or without
cipher" is made much further out (application wiring). With the strict guard
in place, running PXE code without encryption — e.g. for isolation testing,
unit tests, or phase-out of the feature — was a hard error that no consumer
could paper over.

The LMDB and IndexedDB sibling backends already silently ignore the flag
(they have no cipher). Matching that behavior in SQLite-OPFS restores
correctness-preserving fall-back: if the store is unencrypted, keys are
stored in the clear regardless of the hint.

Also updates the corresponding test block from "throws when no cipher" to
"falls back to plaintext when no cipher" and adds a sanity check that the
key column carries ordered-binary (not HMAC) bytes in that case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors src/bench/sqlite-opfs/map_bench.test.ts but opens the store with a
real AesGcmCipher (RawKeyProvider with fresh seed per run). The delta vs
the plaintext bench is the direct cost of cipher.encrypt + cipher.digest
on writes and cipher.decrypt on reads.

Gated by VITE_BENCH=1, consistent with the existing IDB + plaintext
SQLite-OPFS variants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant