From 6cf4eb63ea7ea8eefee70e8b4c9cd7be3746da75 Mon Sep 17 00:00:00 2001 From: mverzilli Date: Tue, 21 Apr 2026 11:50:33 +0000 Subject: [PATCH] feat(wallet): opt in to kv-store encryption-at-rest + inspect helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the new AesGcmCipher from @aztec/kv-store/sqlite-opfs into the PXE + walletDB stores. A 32-byte master seed is generated once and persisted in localStorage under `aztec-kv-cipher-seed-v1`; subsequent loads reuse it so the encrypted OPFS data remains readable across reloads. Changes - walletService.ts: build a shared AesGcmCipher (RawKeyProvider over the persisted seed) and pass it to both AztecSQLiteOPFSStore.open calls. The wallet + PXE get the same HKDF-derived sub-keys. - walletService.ts: VITE_KV_ENCRYPT=0 (or "false") disables the cipher — useful for A/B diagnosis and as a safety escape hatch. Default is on. - walletService.ts: registerSqliteInspectors runs BEFORE EmbeddedWallet.create so the DevTools helpers are reachable even if wallet init hangs or throws. - sqliteInspector.ts: new peekEncryption() reports per-container valueEncrypted / keyLooksHmacd flags at a glance, so you can visually confirm the cipher is active and opaque-keys containers actually HMAC their keys on disk. Fixed summarize()'s reliance on SQLite's `rowid` — the data table is WITHOUT ROWID, so the old query syntax failed. Key provider caveat This is a dev-grade key source: the seed survives page reloads (localStorage) but not device loss or same-origin XSS. A proper IndexedDB-backed unextractable CryptoKey provider — or WebAuthn-PRF — is follow-up work. Validated end-to-end Ran a full swap + claim on testnet with encryption on, then confirmed via peekEncryption() that every audit-flagged container is HMAC'd (32-byte keys): map:notes, map:note_nullifiers_by_contract, map:note_block_number_to_nullifier, map:pending_indexes, map:last_finalized_indexes, map:highest_aged_index, map:highest_finalized_index, map:complete_address_index, map:accounts, map:account_aliases. All 22 populated containers show valueEncrypted: true. Depends on aztec-packages martin/sqlite-with-encryption-at-rest (PR AztecProtocol/aztec-packages#22683). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/walletService.ts | 54 ++++++++++++++++++++++++++++++++--- src/utils/sqliteInspector.ts | 52 +++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/services/walletService.ts b/src/services/walletService.ts index 89b599a..e4de294 100644 --- a/src/services/walletService.ts +++ b/src/services/walletService.ts @@ -9,7 +9,45 @@ import type { ChainInfo } from '@aztec/aztec.js/account'; import { Fr } from '@aztec/aztec.js/fields'; import { createLogger } from '@aztec/foundation/log'; import { AztecSQLiteOPFSStore } from '@aztec/kv-store/sqlite-opfs'; +import { AesGcmCipher, RawKeyProvider } from '@aztec/kv-store/sqlite-opfs'; +import type { ValueCipher } from '@aztec/kv-store/sqlite-opfs'; import { registerSqliteInspectors } from '../utils/sqliteInspector'; + +/** + * localStorage-backed master seed for the kv-store cipher. A 32-byte random seed + * is generated on first use and persisted; subsequent loads reuse it so encrypted + * DBs remain readable across page reloads. Cleared only when the user clears + * localStorage (e.g., "Site data" in DevTools), which is also when the encrypted + * DBs would need to be recreated anyway. + * + * This is a dev-grade key source — it survives reloads but not device loss or + * an attacker who already has origin-scoped JS access. A proper production + * provider (IndexedDB-backed unextractable key / WebAuthn-PRF) is follow-up work. + */ +const CIPHER_SEED_KEY = 'aztec-kv-cipher-seed-v1'; + +function getOrCreateCipherSeed(): Uint8Array { + const stored = localStorage.getItem(CIPHER_SEED_KEY); + if (stored) { + const bytes = new Uint8Array(32); + const decoded = atob(stored); + for (let i = 0; i < 32; i++) { + bytes[i] = decoded.charCodeAt(i); + } + return bytes; + } + const fresh = globalThis.crypto.getRandomValues(new Uint8Array(32)); + let b64 = ''; + for (const b of fresh) { + b64 += String.fromCharCode(b); + } + localStorage.setItem(CIPHER_SEED_KEY, btoa(b64)); + return fresh; +} + +async function buildKvCipher(): Promise { + return AesGcmCipher.create(new RawKeyProvider(getOrCreateCipherSeed())); +} import { WalletManager, type WalletProvider, @@ -50,26 +88,34 @@ export async function createEmbeddedWallet( // cross-contaminate. const l1Contracts = await node.getL1ContractAddresses(); const rollup = l1Contracts.rollupAddress.toString(); + // Isolation toggle: set VITE_KV_ENCRYPT=0 to run without encryption for A/B + // diagnosis. Undefined / "1" / "true" → encryption on (default). + const encryptKv = import.meta.env.VITE_KV_ENCRYPT !== '0' && import.meta.env.VITE_KV_ENCRYPT !== 'false'; + const cipher = encryptKv ? await buildKvCipher() : undefined; const pxeStore = await AztecSQLiteOPFSStore.open( createLogger('pxe:data:sqlite-opfs'), `pxe_data_${rollup}`, false, `.aztec-kv-pxe-${rollup}`, + cipher, ); const walletStore = await AztecSQLiteOPFSStore.open( createLogger('wallet:data:sqlite-opfs'), `wallet_data_${rollup}`, false, `.aztec-kv-wallet-${rollup}`, + cipher, ); + if (import.meta.env.DEV) { + // Register inspectors BEFORE EmbeddedWallet.create so they're reachable from + // the DevTools console even if wallet init hangs or throws (e.g. when stale + // plaintext OPFS data can't be decrypted). See sqliteInspector.ts. + registerSqliteInspectors({ pxe: pxeStore, wallet: walletStore }); + } const wallet = await EmbeddedWallet.create(node, { pxe: { proverEnabled: true, store: pxeStore }, walletDb: { store: walletStore }, }); - if (import.meta.env.DEV) { - // Expose dev-only inspectors at `window.__aztecStores`. See sqliteInspector.ts. - registerSqliteInspectors({ pxe: pxeStore, wallet: walletStore }); - } let accountManager = await wallet.loadStoredAccount(); if (!accountManager) { accountManager = await wallet.createInitializerlessAccount(); diff --git a/src/utils/sqliteInspector.ts b/src/utils/sqliteInspector.ts index dc79f2d..6776419 100644 --- a/src/utils/sqliteInspector.ts +++ b/src/utils/sqliteInspector.ts @@ -39,6 +39,51 @@ async function summarize(store: InspectableStore): Promise ({ container: String(r[0]), rows: Number(r[1]) })); } +/** AES-GCM ciphertexts written by AesGcmCipher start with 0x01 (the version byte). + * HMAC-SHA-256 always produces 32 bytes, so HMAC'd key columns show that width. */ +const AES_GCM_VERSION_BYTE = 0x01; +const HMAC_SHA256_BYTES = 32; + +/** + * Samples one row per container and reports whether the value looks encrypted and + * the key looks HMAC'd. A quick visual confirmation that the cipher is wired up + * correctly — a plaintext store shows `valueEncrypted: false` across the board. + * + * Done as "list containers, then fetch one row each" because the `data` table is + * `WITHOUT ROWID` (slot is the PK), so `rowid` doesn't exist. + */ +async function peek(store: InspectableStore): Promise< + Array<{ container: string; valueEncrypted: boolean; keyLooksHmacd: boolean; sampleKeyBytes: number; sampleValueBytes: number; rows: number }> +> { + const containers = await store.allAsync( + 'SELECT container, count(*) AS n FROM data GROUP BY container ORDER BY container', + ); + const out: Array<{ + container: string; + valueEncrypted: boolean; + keyLooksHmacd: boolean; + sampleKeyBytes: number; + sampleValueBytes: number; + rows: number; + }> = []; + for (const row of containers) { + const container = String(row[0]); + const rowCount = Number(row[1]); + const sample = await store.allAsync('SELECT key, value FROM data WHERE container = ? LIMIT 1', [container]); + const key = sample[0]?.[0] as Uint8Array | null; + const value = sample[0]?.[1] as Uint8Array | null; + out.push({ + container, + valueEncrypted: value instanceof Uint8Array && value.length > 0 && value[0] === AES_GCM_VERSION_BYTE, + keyLooksHmacd: key instanceof Uint8Array && key.length === HMAC_SHA256_BYTES, + sampleKeyBytes: key instanceof Uint8Array ? key.length : 0, + sampleValueBytes: value instanceof Uint8Array ? value.length : 0, + rows: rowCount, + }); + } + return out; +} + /** Stores exposed for inspection, plus their bound helpers. */ export type SqliteInspectors = { pxe: InspectableStore; @@ -49,6 +94,9 @@ export type SqliteInspectors = { downloadWallet(): Promise; /** Prints container/row-count summaries for both stores (console-friendly). */ summary(): Promise<{ pxe: Array<{ container: string; rows: number }>; wallet: Array<{ container: string; rows: number }> }>; + /** Samples one row per container in each store and reports whether values are + * encrypted (AES-GCM version byte) and whether keys look HMAC'd (32 bytes). */ + peekEncryption(): Promise<{ pxe: Awaited>; wallet: Awaited> }>; }; /** @@ -68,6 +116,10 @@ export function registerSqliteInspectors(stores: { pxe: InspectableStore; wallet pxe: await summarize(stores.pxe), wallet: await summarize(stores.wallet), }), + peekEncryption: async () => ({ + pxe: await peek(stores.pxe), + wallet: await peek(stores.wallet), + }), }; (window as unknown as { __aztecStores: SqliteInspectors }).__aztecStores = inspectors; }