Skip to content

Commit 7d968b2

Browse files
authored
feat: backport kv-store sqlite encryption (#22759) to v4-next (#22927)
## Summary Backport of #22759 (`feat: kv-store sqlite backend with page level encryption`) to `backport-to-v4-next-staging`. The auto-cherry-pick failed on `yarn-project/kv-store/src/sqlite-opfs/worker.ts`. The conflict was localized to the `ensurePool` function: the v4-next branch already contains #22721 (kv-store browser test hangs fix), which added a `poolDirectory = directory;` assignment, while #22759 introduced a local `s = sqlite3` alias and the sqlite3mc VFS registration call. Both changes are compatible and have been combined. ## Commit structure 1. `1944712b` — original cherry-pick committed as-is with the conflict marker preserved in `worker.ts`, so reviewers can see exactly what conflicted. 2. `0c397e2c` — conflict resolution: keeps `poolDirectory` tracking from #22721 + the local `s` alias and `sqlite3mc_vfs_create` call from #22759. No build-fixes commit was needed — `kv-store` and `sqlite3mc-wasm` both typecheck clean against the resolved tree. ## Verification - `yarn install` succeeded. - `tsgo --noEmit` is clean for both `kv-store` and `sqlite3mc-wasm`. - Diff stat matches the original PR: 26 files, +699/-14. ## Original PR - #22759 - Merge commit: `97c6e48fe9bf269d12fa0640e4e9303f5e7cbae5` ClaudeBox log: https://claudebox.work/s/6446d1f92380c25a?run=1 ClaudeBox log: https://claudebox.work/s/6446d1f92380c25a?run=1
2 parents e9d343c + 8c47ade commit 7d968b2

26 files changed

Lines changed: 719 additions & 17 deletions

yarn-project/.prettierignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,5 @@ noir-protocol-circuits-types/src/vk_tree.ts
2020
noir-protocol-circuits-types/src/client_artifacts_helper.ts
2121
constants/src/constants.gen.ts
2222
cli/src/config/generated
23+
sqlite3mc-wasm/vendor/
24+

yarn-project/bootstrap.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ function compile_all {
119119
return
120120
fi
121121

122+
# Ensure the pinned version sqlite3mc-wasm upstream artifacts are present before any package builds.
123+
./sqlite3mc-wasm/scripts/vendor.sh ensure
124+
122125
compile_project ::: constants foundation stdlib blob-lib builder ethereum l1-artifacts
123126

124127
# Call all projects that have a generation stage.

yarn-project/kv-store/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@
3131
"@aztec/ethereum": "workspace:^",
3232
"@aztec/foundation": "workspace:^",
3333
"@aztec/native": "workspace:^",
34+
"@aztec/sqlite3mc-wasm": "workspace:^",
3435
"@aztec/stdlib": "workspace:^",
35-
"@sqlite.org/sqlite-wasm": "3.50.4-build1",
3636
"idb": "^8.0.0",
3737
"lmdb": "^3.2.0",
3838
"msgpackr": "^1.11.2",
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Map benchmark suite for the SQLite-OPFS backend with page-level encryption
3+
* enabled (sqlite3mc ChaCha20). Runs the exact same workload as
4+
* `bench/sqlite-opfs/map_bench.test.ts` — the delta between the two runs is
5+
* the full cost of page-level encryption on reads and writes.
6+
*
7+
* Uses persistent OPFS-backed stores (sqlite3mc does not support encryption
8+
* on :memory: databases). Each run creates its own unique SAH Pool directory.
9+
*
10+
* Skipped by default; set VITE_BENCH=1 (and VITE_SQLITE_OPFS=1) to run.
11+
*/
12+
import { createLogger } from '@aztec/foundation/log';
13+
14+
import { mockLogger } from '../../interfaces/utils.js';
15+
import { AztecSQLiteOPFSStore } from '../../sqlite-opfs/store.js';
16+
import { describeAztecMapBench } from '../shared_map_bench.js';
17+
18+
const shouldRun = (import.meta as ImportMeta & { env?: { VITE_BENCH?: string } }).env?.VITE_BENCH === '1';
19+
20+
if (shouldRun) {
21+
describeAztecMapBench(
22+
'SQLite-OPFS (chacha20)',
23+
() => {
24+
const key = globalThis.crypto.getRandomValues(new Uint8Array(32));
25+
const name = `bench-enc-${Date.now()}`;
26+
const dir = `/bench-enc-pool-${Date.now()}`;
27+
return AztecSQLiteOPFSStore.open(mockLogger, name, false, dir, key);
28+
},
29+
createLogger('kv-store:map:benchmarks:sqlite-opfs-chacha20'),
30+
() => {},
31+
);
32+
} else {
33+
describe.skip('SQLite-OPFS (chacha20) Map benchmarks (set VITE_BENCH=1 to run)', () => {
34+
it('skipped', () => {});
35+
});
36+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Tests for sqlite3mc-backed page-level encryption.
3+
* Exercises the `encryptionKey` parameter added to `AztecSQLiteOPFSStore.open()`.
4+
* Mixes ephemeral and persistent stores so we cover both `:memory:` and OPFS paths.
5+
*/
6+
import { describe, expect, it } from 'vitest';
7+
8+
import { mockLogger } from '../interfaces/utils.js';
9+
import { AztecSQLiteOPFSStore } from './store.js';
10+
11+
function randomKey(): Uint8Array {
12+
return globalThis.crypto.getRandomValues(new Uint8Array(32));
13+
}
14+
15+
/**
16+
* Clone a key into a fresh Uint8Array. `AztecSQLiteOPFSStore.open()` transfers
17+
* the key's ArrayBuffer to the worker (detaching it on the caller side), so
18+
* callers that open multiple stores or re-use a key must pass a copy per call.
19+
*/
20+
function cloneKey(k: Uint8Array): Uint8Array {
21+
return new Uint8Array(k);
22+
}
23+
24+
describe('AztecSQLiteOPFSStore with encryption', () => {
25+
it('rejects keys that are not 32 bytes', async () => {
26+
const short = new Uint8Array(16);
27+
await expect(AztecSQLiteOPFSStore.open(mockLogger, undefined, true, undefined, short)).rejects.toThrow(
28+
/encryptionKey must be 32 bytes/,
29+
);
30+
});
31+
32+
it('rejects encryption on ephemeral (:memory:) stores', async () => {
33+
const key = randomKey();
34+
await expect(AztecSQLiteOPFSStore.open(mockLogger, undefined, true, undefined, key)).rejects.toThrow(
35+
/not supported for ephemeral/,
36+
);
37+
});
38+
39+
it('round-trips values with the same key (persistent)', async () => {
40+
const key = randomKey();
41+
const name = `test-enc-${Date.now()}`;
42+
const dir = `/test-enc-pool-${Date.now()}`;
43+
const a = await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir, cloneKey(key));
44+
const mapA = a.openMap<string, string>('m');
45+
await mapA.set('k1', 'v1');
46+
await a.close();
47+
48+
const b = await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir, cloneKey(key));
49+
const mapB = b.openMap<string, string>('m');
50+
expect(await mapB.getAsync('k1')).toBe('v1');
51+
await b.delete();
52+
});
53+
54+
it('fails to open with the wrong key', async () => {
55+
const key = randomKey();
56+
const name = `test-wrong-${Date.now()}`;
57+
const dir = `/test-wrong-pool-${Date.now()}`;
58+
const a = await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir, cloneKey(key));
59+
await a.openMap<string, string>('m').set('k1', 'v1');
60+
await a.close();
61+
62+
await expect(async () => {
63+
const b = await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir, randomKey());
64+
await b.openMap<string, string>('m').getAsync('k1');
65+
await b.close();
66+
}).rejects.toThrow();
67+
68+
const c = await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir, cloneKey(key));
69+
await c.delete();
70+
});
71+
72+
it('fails to open an encrypted DB without a key', async () => {
73+
const key = randomKey();
74+
const name = `test-nokey-${Date.now()}`;
75+
const dir = `/test-nokey-pool-${Date.now()}`;
76+
const a = await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir, cloneKey(key));
77+
await a.openMap<string, string>('m').set('k1', 'v1');
78+
await a.close();
79+
80+
await expect(async () => {
81+
const b = await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir);
82+
await b.openMap<string, string>('m').getAsync('k1');
83+
await b.close();
84+
}).rejects.toThrow();
85+
86+
const c = await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir, cloneKey(key));
87+
await c.delete();
88+
});
89+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Smoke test: runs the same basic read/write dance against every kv-store interface
3+
* using an encrypted store, to verify the interfaces are oblivious to the cipher
4+
* layer. Each test uses its own persistent OPFS directory because sqlite3mc does
5+
* not support encryption on ephemeral (:memory:) stores.
6+
*/
7+
import { afterEach, describe, expect, it } from 'vitest';
8+
9+
import { mockLogger } from '../interfaces/utils.js';
10+
import { AztecSQLiteOPFSStore } from './store.js';
11+
12+
function randomKey(): Uint8Array {
13+
return globalThis.crypto.getRandomValues(new Uint8Array(32));
14+
}
15+
16+
describe('encrypted store smoke: interfaces', () => {
17+
let openedStore: AztecSQLiteOPFSStore | undefined;
18+
19+
afterEach(async () => {
20+
if (openedStore) {
21+
await openedStore.delete();
22+
openedStore = undefined;
23+
}
24+
});
25+
26+
async function openStore(label: string): Promise<AztecSQLiteOPFSStore> {
27+
const key = randomKey();
28+
const name = `smoke-${label}-${Date.now()}`;
29+
const dir = `/smoke-${label}-pool-${Date.now()}`;
30+
const s = await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir, key);
31+
openedStore = s;
32+
return s;
33+
}
34+
35+
it('AztecMap', async () => {
36+
const s = await openStore('map');
37+
const m = s.openMap<string, number>('m');
38+
await m.set('a', 1);
39+
await m.set('b', 2);
40+
expect(await m.getAsync('a')).toBe(1);
41+
expect(await m.getAsync('b')).toBe(2);
42+
});
43+
44+
it('AztecSet', async () => {
45+
const s = await openStore('set');
46+
const set = s.openSet<string>('s');
47+
await set.add('a');
48+
await set.add('b');
49+
expect(await set.hasAsync('a')).toBe(true);
50+
expect(await set.hasAsync('z')).toBe(false);
51+
});
52+
53+
it('AztecSingleton', async () => {
54+
const s = await openStore('singleton');
55+
const sing = s.openSingleton<{ n: number }>('sg');
56+
await sing.set({ n: 42 });
57+
expect(await sing.getAsync()).toEqual({ n: 42 });
58+
});
59+
60+
it('AztecArray', async () => {
61+
const s = await openStore('array');
62+
const arr = s.openArray<string>('a');
63+
await arr.push('x', 'y');
64+
expect(await arr.lengthAsync()).toBe(2);
65+
});
66+
67+
it('AztecMultiMap', async () => {
68+
const s = await openStore('multimap');
69+
const mm = s.openMultiMap<string, number>('mm');
70+
await mm.set('k', 1);
71+
await mm.set('k', 2);
72+
const vals: number[] = [];
73+
for await (const v of mm.getValuesAsync('k')) {
74+
vals.push(v);
75+
}
76+
expect(vals.sort()).toEqual([1, 2]);
77+
});
78+
});

yarn-project/kv-store/src/sqlite-opfs/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,13 @@ export async function createStore(
2525
export function openTmpStore(ephemeral: boolean = false): Promise<AztecSQLiteOPFSStore> {
2626
return AztecSQLiteOPFSStore.open(createLogger('kv-store:sqlite-opfs'), undefined, ephemeral);
2727
}
28+
29+
/**
30+
* Convenience helper for tests and consumers that want an encrypted sqlite-opfs
31+
* store without dealing with the full `open()` parameter order. Key must be 32
32+
* bytes. Creates a fresh persistent store (sqlite3mc does not support encryption
33+
* on ephemeral `:memory:` databases) in an auto-generated OPFS directory.
34+
*/
35+
export function openEncryptedStore(encryptionKey: Uint8Array, name?: string, poolDirectory?: string) {
36+
return AztecSQLiteOPFSStore.open(createLogger('kv-store:sqlite-opfs'), name, false, poolDirectory, encryptionKey);
37+
}

yarn-project/kv-store/src/sqlite-opfs/messages.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export type SqlValue = string | number | bigint | null | Uint8Array;
1111
export type ResultRow = SqlValue[];
1212

1313
export type WorkerRequest =
14-
| { type: 'init'; id: number; dbName: string; ephemeral: boolean; poolDirectory?: string }
14+
| { type: 'init'; id: number; dbName: string; ephemeral: boolean; poolDirectory?: string; encryptionKey?: Uint8Array }
1515
| { type: 'close'; id: number }
1616
| { type: 'deleteDb'; id: number; dbName: string }
1717
| { type: 'run'; id: number; sql: string; bind?: SqlValue[] }

yarn-project/kv-store/src/sqlite-opfs/store.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,18 +68,48 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
6868
* Pass `poolDirectory` to place the SAH Pool in a non-default OPFS subdirectory —
6969
* required when multiple stores coexist in the same tab, because the SAH Pool holds
7070
* an exclusive lock on its directory.
71+
*
72+
* Pass `encryptionKey` (exactly 32 bytes) to enable at-rest encryption via sqlite3mc's
73+
* ChaCha20 page cipher. The key buffer is **transferred** to the worker — its
74+
* ArrayBuffer detaches on the caller side after `postMessage`. This is intentional:
75+
* the API encodes a one-key-one-owner invariant. A caller that wants to use the same
76+
* key for multiple stores must explicitly clone it per call (e.g.
77+
* `new Uint8Array(savedKey)`), making the duplication a visible, deliberate decision
78+
* rather than a silent structured-clone operation. The default path (one `.open()`,
79+
* one consumption of the key) leaves zero key bytes on the main thread after the call.
7180
*/
7281
static async open(
7382
log: Logger,
7483
name?: string,
7584
ephemeral: boolean = false,
7685
poolDirectory?: string,
86+
encryptionKey?: Uint8Array,
7787
): Promise<AztecSQLiteOPFSStore> {
88+
if (encryptionKey !== undefined && encryptionKey.length !== 32) {
89+
throw new Error(`encryptionKey must be 32 bytes (got ${encryptionKey.length})`);
90+
}
91+
if (encryptionKey !== undefined && ephemeral) {
92+
throw new Error('encryptionKey is not supported for ephemeral (:memory:) stores');
93+
}
7894
const dbName = name && !ephemeral ? name : `tmp-${globalThis.crypto.getRandomValues(new Uint8Array(8)).join('')}`;
79-
log.debug(`Opening SQLite-OPFS ${ephemeral ? 'ephemeral ' : ''}database ${dbName}`);
95+
log.debug(
96+
`Opening SQLite-OPFS ${ephemeral ? 'ephemeral ' : ''}${encryptionKey ? 'encrypted ' : ''}database ${dbName}`,
97+
);
8098
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
8199
const store = new AztecSQLiteOPFSStore(worker, dbName, log, ephemeral);
82-
await store.#sendRequest({ type: 'init', id: store.#allocId(), dbName, ephemeral, poolDirectory });
100+
// Transfer (not clone) the key buffer to the worker so we don't leave a
101+
// second copy on the main thread. Caveat: this detaches the caller's
102+
// encryptionKey.buffer — subsequent reads from the same Uint8Array are empty.
103+
const transfer = encryptionKey ? [encryptionKey.buffer as ArrayBuffer] : undefined;
104+
try {
105+
await store.#sendRequest(
106+
{ type: 'init', id: store.#allocId(), dbName, ephemeral, poolDirectory, encryptionKey },
107+
transfer,
108+
);
109+
} catch (err) {
110+
worker.terminate();
111+
throw err;
112+
}
83113
return store;
84114
}
85115

@@ -230,7 +260,7 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
230260
this.#pending.clear();
231261
}
232262

233-
#sendRequest(req: WorkerRequest): Promise<WorkerResponse> {
263+
#sendRequest(req: WorkerRequest, transfer?: Transferable[]): Promise<WorkerResponse> {
234264
return new Promise<WorkerResponse>((resolve, reject) => {
235265
this.#pending.set(req.id, {
236266
resolve: resp => {
@@ -242,7 +272,11 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
242272
},
243273
reject,
244274
});
245-
this.#worker.postMessage(req);
275+
if (transfer && transfer.length > 0) {
276+
this.#worker.postMessage(req, transfer);
277+
} else {
278+
this.#worker.postMessage(req);
279+
}
246280
});
247281
}
248282
}

0 commit comments

Comments
 (0)