Skip to content

Commit 5f32fa1

Browse files
mverzilliclaude
authored andcommitted
chore: better encrypted sqlite ergonomics (#23231)
Adds some structure to typical decryption errors, and a couple of convenience helpers for orchestrating encrypted store management from embedded wallet, so that the most basic usage is straightforward and downstream projects don't need to rewrite the same lines over and over. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 19424e1 commit 5f32fa1

11 files changed

Lines changed: 447 additions & 25 deletions

File tree

playground/vite.config.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@ const chunkSizeValidator = (limits: ChunkSizeLimit[]): Plugin => {
4545
configResolved(resolvedConfig) {
4646
config = resolvedConfig;
4747
},
48-
closeBundle() {
48+
// `writeBundle` is documented to fire AFTER the output bundle has been
49+
// written to disk, whereas `closeBundle` (which we used previously) is the
50+
// last hook to run and can fire before any chunks have been flushed in
51+
// current vite/rollup versions — manifesting as ENOENT on `scandir 'dist'`
52+
// for a build that otherwise transformed all modules cleanly.
53+
writeBundle() {
4954
const outDir = this.meta?.watchMode ? null : 'dist';
5055
if (!outDir) return; // Skip in watch mode
5156

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

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { describe, expect, it } from 'vitest';
77

88
import { mockLogger } from '../interfaces/utils.js';
9+
import { SqliteEncryptionError } from './errors.js';
910
import { AztecSQLiteOPFSStore } from './store.js';
1011

1112
function randomKey(): Uint8Array {
@@ -24,16 +25,16 @@ function cloneKey(k: Uint8Array): Uint8Array {
2425
describe('AztecSQLiteOPFSStore with encryption', () => {
2526
it('rejects keys that are not 32 bytes', async () => {
2627
const short = new Uint8Array(16);
27-
await expect(AztecSQLiteOPFSStore.open(mockLogger, undefined, true, undefined, short)).rejects.toThrow(
28-
/encryptionKey must be 32 bytes/,
29-
);
28+
const promise = AztecSQLiteOPFSStore.open(mockLogger, undefined, true, undefined, short);
29+
await expect(promise).rejects.toThrow(SqliteEncryptionError);
30+
await expect(promise).rejects.toMatchObject({ code: 'invalid_key_length' });
3031
});
3132

3233
it('rejects encryption on ephemeral (:memory:) stores', async () => {
3334
const key = randomKey();
34-
await expect(AztecSQLiteOPFSStore.open(mockLogger, undefined, true, undefined, key)).rejects.toThrow(
35-
/not supported for ephemeral/,
36-
);
35+
const promise = AztecSQLiteOPFSStore.open(mockLogger, undefined, true, undefined, key);
36+
await expect(promise).rejects.toThrow(SqliteEncryptionError);
37+
await expect(promise).rejects.toMatchObject({ code: 'encryption_not_supported_for_ephemeral' });
3738
});
3839

3940
it('round-trips values with the same key (persistent)', async () => {
@@ -51,37 +52,43 @@ describe('AztecSQLiteOPFSStore with encryption', () => {
5152
await b.delete();
5253
});
5354

54-
it('fails to open with the wrong key', async () => {
55+
it('fails to open with the wrong key (throws SqliteEncryptionError, code=decrypt_failed)', async () => {
5556
const key = randomKey();
5657
const name = `test-wrong-${Date.now()}`;
5758
const dir = `/test-wrong-pool-${Date.now()}`;
5859
const a = await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir, cloneKey(key));
5960
await a.openMap<string, string>('m').set('k1', 'v1');
6061
await a.close();
6162

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();
63+
let caught: unknown;
64+
try {
65+
await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir, randomKey());
66+
} catch (err) {
67+
caught = err;
68+
}
69+
expect(caught).toBeInstanceOf(SqliteEncryptionError);
70+
expect((caught as SqliteEncryptionError).code).toBe('decrypt_failed');
6771

6872
const c = await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir, cloneKey(key));
6973
await c.delete();
7074
});
7175

72-
it('fails to open an encrypted DB without a key', async () => {
76+
it('fails to open an encrypted DB without a key (throws SqliteEncryptionError, code=decrypt_failed)', async () => {
7377
const key = randomKey();
7478
const name = `test-nokey-${Date.now()}`;
7579
const dir = `/test-nokey-pool-${Date.now()}`;
7680
const a = await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir, cloneKey(key));
7781
await a.openMap<string, string>('m').set('k1', 'v1');
7882
await a.close();
7983

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();
84+
let caught: unknown;
85+
try {
86+
await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir);
87+
} catch (err) {
88+
caught = err;
89+
}
90+
expect(caught).toBeInstanceOf(SqliteEncryptionError);
91+
expect((caught as SqliteEncryptionError).code).toBe('decrypt_failed');
8592

8693
const c = await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir, cloneKey(key));
8794
await c.delete();
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Typed error surface for sqlite3mc-backed page-level encryption failures.
3+
*
4+
* Three concrete failure modes are surfaced:
5+
*
6+
* - `invalid_key_length`: caller-side pre-flight (key not 32 bytes).
7+
* - `encryption_not_supported_for_ephemeral`: caller-side pre-flight (encryption was requested on an ephemeral
8+
* `:memory:` store, which sqlite3mc does not support).
9+
* - `decrypt_failed`: runtime failure raised when sqlite3mc cannot decode page 1 of an existing database. Covers
10+
* both "wrong key supplied" and "no key supplied to an encrypted DB".
11+
*/
12+
export type SqliteEncryptionErrorCode =
13+
| 'invalid_key_length'
14+
| 'encryption_not_supported_for_ephemeral'
15+
| 'decrypt_failed';
16+
17+
/**
18+
* Error thrown by sqlite-opfs when an encryption operation fails.
19+
**/
20+
export class SqliteEncryptionError extends Error {
21+
readonly code: SqliteEncryptionErrorCode;
22+
23+
constructor(code: SqliteEncryptionErrorCode, message: string, opts?: { cause?: unknown }) {
24+
super(message, opts?.cause !== undefined ? { cause: opts.cause } : undefined);
25+
this.name = 'SqliteEncryptionError';
26+
this.code = code;
27+
}
28+
}
29+
30+
/**
31+
* Strings raised by sqlite3mc when page 1 cannot be decoded.
32+
**/
33+
const SQLITE3MC_DECRYPT_ERROR_PATTERNS: readonly RegExp[] = [
34+
/file is not a database/i,
35+
/file is encrypted or is not a database/i,
36+
];
37+
38+
/**
39+
* Returns `true` if `message` matches one of the known sqlite3mc decrypt-failure
40+
* strings.
41+
**/
42+
export function isDecryptFailureMessage(message: string): boolean {
43+
return SQLITE3MC_DECRYPT_ERROR_PATTERNS.some(p => p.test(message));
44+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { initStoreForRollupAndSchemaVersion } from '../utils.js';
55
import { AztecSQLiteOPFSStore } from './store.js';
66

77
export { AztecSQLiteOPFSStore } from './store.js';
8+
export { SqliteEncryptionError } from './errors.js';
9+
export type { SqliteEncryptionErrorCode } from './errors.js';
810

911
export async function createStore(
1012
name: string,

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
* All requests carry a unique `id`; responses echo the same id so the main thread
44
* can resolve the right pending promise.
55
*/
6+
import type { SqliteEncryptionErrorCode } from './errors.js';
67

78
/** Matches `@sqlite.org/sqlite-wasm`'s internal SqlValue type. Boolean is not a native SQLite type. */
89
export type SqlValue = string | number | bigint | null | Uint8Array;
@@ -23,6 +24,16 @@ export type WorkerRequest =
2324

2425
export type WorkerResponse =
2526
| { type: 'ok'; id: number; rows?: ResultRow[]; changes?: number; bytes?: Uint8Array }
26-
| { type: 'err'; id: number; message: string };
27+
| {
28+
type: 'err';
29+
id: number;
30+
message: string;
31+
/**
32+
* Set when the worker detected an encryption-shaped failure. The main thread
33+
* uses this to re-throw the error as a {@link SqliteEncryptionError} with the
34+
* matching code. Absent on non-encryption failures (untyped paths preserved).
35+
*/
36+
encryptionCode?: SqliteEncryptionErrorCode;
37+
};
2738

2839
export type WorkerRequestType = WorkerRequest['type'];

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { AztecAsyncSet } from '../interfaces/set.js';
1010
import type { AztecAsyncSingleton } from '../interfaces/singleton.js';
1111
import type { AztecAsyncKVStore } from '../interfaces/store.js';
1212
import { SQLiteOPFSAztecArray } from './array.js';
13+
import { SqliteEncryptionError } from './errors.js';
1314
import { SQLiteOPFSAztecMap } from './map.js';
1415
import type { ResultRow, SqlValue, WorkerRequest, WorkerResponse } from './messages.js';
1516
import { SQLiteOPFSAztecMultiMap } from './multi_map.js';
@@ -86,10 +87,16 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
8687
encryptionKey?: Uint8Array,
8788
): Promise<AztecSQLiteOPFSStore> {
8889
if (encryptionKey !== undefined && encryptionKey.length !== 32) {
89-
throw new Error(`encryptionKey must be 32 bytes (got ${encryptionKey.length})`);
90+
throw new SqliteEncryptionError(
91+
'invalid_key_length',
92+
`encryptionKey must be 32 bytes (got ${encryptionKey.length})`,
93+
);
9094
}
9195
if (encryptionKey !== undefined && ephemeral) {
92-
throw new Error('encryptionKey is not supported for ephemeral (:memory:) stores');
96+
throw new SqliteEncryptionError(
97+
'encryption_not_supported_for_ephemeral',
98+
'encryptionKey is not supported for ephemeral (:memory:) stores',
99+
);
93100
}
94101
const dbName = name && !ephemeral ? name : `tmp-${globalThis.crypto.getRandomValues(new Uint8Array(8)).join('')}`;
95102
log.debug(
@@ -265,7 +272,14 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
265272
this.#pending.set(req.id, {
266273
resolve: resp => {
267274
if (resp.type === 'err') {
268-
reject(new Error(resp.message));
275+
// Re-hydrate encryption-shaped errors as the typed class so consumers
276+
// can pattern-match on `instanceof SqliteEncryptionError`. Plain
277+
// errors stay plain — the wire protocol only tags encryption paths.
278+
if (resp.encryptionCode !== undefined) {
279+
reject(new SqliteEncryptionError(resp.encryptionCode, resp.message));
280+
} else {
281+
reject(new Error(resp.message));
282+
}
269283
} else {
270284
resolve(resp);
271285
}

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

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/// <reference lib="webworker" />
22
import sqlite3InitModule, { type Database, type SAHPoolUtil, type Sqlite3Static } from '@aztec/sqlite3mc-wasm';
33

4+
import { SqliteEncryptionError, type SqliteEncryptionErrorCode, isDecryptFailureMessage } from './errors.js';
45
import type { ResultRow, SqlValue, WorkerRequest, WorkerResponse } from './messages.js';
56

67
const SCHEMA_SQL = `
@@ -72,7 +73,10 @@ async function handleInit(
7273
sqlite3 ??= await sqlite3InitModule();
7374
const s = sqlite3;
7475
if (encryptionKey !== undefined && ephemeral) {
75-
throw new Error('encryptionKey is not supported for ephemeral (:memory:) stores');
76+
throw new SqliteEncryptionError(
77+
'encryption_not_supported_for_ephemeral',
78+
'encryptionKey is not supported for ephemeral (:memory:) stores',
79+
);
7680
}
7781
if (ephemeral) {
7882
db = new s.oo1.DB(':memory:', 'c');
@@ -189,6 +193,34 @@ function respond(msg: WorkerResponse): void {
189193
}
190194
} catch (err) {
191195
const message = err instanceof Error ? err.message : String(err);
192-
respond({ type: 'err', id: req.id, message });
196+
respond({ type: 'err', id: req.id, message, encryptionCode: detectEncryptionCode(req, err, message) });
193197
}
194198
};
199+
200+
/**
201+
* Maps a thrown error during request handling to a typed encryption code, so the
202+
* main thread can re-hydrate it as a {@link SqliteEncryptionError}. Returns
203+
* `undefined` for non-encryption errors (preserves the existing untyped path).
204+
*
205+
* Two sources:
206+
* - The error was already a `SqliteEncryptionError` (pre-flight throws inside
207+
* this worker — e.g. ephemeral + encryptionKey). Forward its code as-is.
208+
* - The error came from SQLite/sqlite3mc with a known decrypt-failure message
209+
* during an `init` request. Both "wrong key supplied" and "no key supplied
210+
* to an encrypted DB" surface as SQLITE_NOTADB ("file is not a database") —
211+
* we don't constrain on `req.encryptionKey` because the no-key-on-encrypted-DB
212+
* case is exactly when the caller most needs the typed signal.
213+
*/
214+
function detectEncryptionCode(
215+
req: WorkerRequest,
216+
err: unknown,
217+
message: string,
218+
): SqliteEncryptionErrorCode | undefined {
219+
if (err instanceof SqliteEncryptionError) {
220+
return err.code;
221+
}
222+
if (req.type === 'init' && isDecryptFailureMessage(message)) {
223+
return 'decrypt_failed';
224+
}
225+
return undefined;
226+
}

yarn-project/wallets/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"default": "./dest/embedded/entrypoints/node.js"
1515
}
1616
},
17+
"./embedded/store-encryption": "./dest/embedded/store_encryption.js",
1718
"./testing": "./dest/testing.js"
1819
},
1920
"scripts": {

yarn-project/wallets/src/embedded/entrypoints/browser.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,3 +77,10 @@ export { BrowserEmbeddedWallet as EmbeddedWallet };
7777
export type { EmbeddedWalletOptions, EmbeddedWalletPXEOptions } from '../embedded_wallet.js';
7878
export { WalletDB } from '../wallet_db.js';
7979
export type { AccountType } from '../wallet_db.js';
80+
81+
// At-rest encryption helpers are intentionally NOT re-exported here. They live
82+
// on the `@aztec/wallets/embedded/store-encryption` sub-path so consumers
83+
// (and bundlers) of this entrypoint don't transitively pull in
84+
// `@aztec/kv-store/sqlite-opfs` and its `new Worker(new URL('./worker.js'))`
85+
// chain into `@aztec/sqlite3mc-wasm`. Apps that don't use encryption-at-rest
86+
// (e.g. the playground) should never see sqlite-opfs in their bundle.

0 commit comments

Comments
 (0)