Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion playground/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,12 @@ const chunkSizeValidator = (limits: ChunkSizeLimit[]): Plugin => {
configResolved(resolvedConfig) {
config = resolvedConfig;
},
closeBundle() {
// `writeBundle` is documented to fire AFTER the output bundle has been
// written to disk, whereas `closeBundle` (which we used previously) is the
// last hook to run and can fire before any chunks have been flushed in
// current vite/rollup versions — manifesting as ENOENT on `scandir 'dist'`
// for a build that otherwise transformed all modules cleanly.
writeBundle() {
const outDir = this.meta?.watchMode ? null : 'dist';
if (!outDir) return; // Skip in watch mode

Expand Down
43 changes: 25 additions & 18 deletions yarn-project/kv-store/src/sqlite-opfs/encrypted_store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import { describe, expect, it } from 'vitest';

import { mockLogger } from '../interfaces/utils.js';
import { SqliteEncryptionError } from './errors.js';
import { AztecSQLiteOPFSStore } from './store.js';

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

it('rejects encryption on ephemeral (:memory:) stores', async () => {
const key = randomKey();
await expect(AztecSQLiteOPFSStore.open(mockLogger, undefined, true, undefined, key)).rejects.toThrow(
/not supported for ephemeral/,
);
const promise = AztecSQLiteOPFSStore.open(mockLogger, undefined, true, undefined, key);
await expect(promise).rejects.toThrow(SqliteEncryptionError);
await expect(promise).rejects.toMatchObject({ code: 'encryption_not_supported_for_ephemeral' });
});

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

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

await expect(async () => {
const b = await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir, randomKey());
await b.openMap<string, string>('m').getAsync('k1');
await b.close();
}).rejects.toThrow();
let caught: unknown;
try {
await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir, randomKey());
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(SqliteEncryptionError);
expect((caught as SqliteEncryptionError).code).toBe('decrypt_failed');

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

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

await expect(async () => {
const b = await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir);
await b.openMap<string, string>('m').getAsync('k1');
await b.close();
}).rejects.toThrow();
let caught: unknown;
try {
await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir);
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(SqliteEncryptionError);
expect((caught as SqliteEncryptionError).code).toBe('decrypt_failed');

const c = await AztecSQLiteOPFSStore.open(mockLogger, name, false, dir, cloneKey(key));
await c.delete();
Expand Down
44 changes: 44 additions & 0 deletions yarn-project/kv-store/src/sqlite-opfs/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Typed error surface for sqlite3mc-backed page-level encryption failures.
*
* Three concrete failure modes are surfaced:
*
* - `invalid_key_length`: caller-side pre-flight (key not 32 bytes).
* - `encryption_not_supported_for_ephemeral`: caller-side pre-flight (encryption was requested on an ephemeral
* `:memory:` store, which sqlite3mc does not support).
* - `decrypt_failed`: runtime failure raised when sqlite3mc cannot decode page 1 of an existing database. Covers
* both "wrong key supplied" and "no key supplied to an encrypted DB".
*/
export type SqliteEncryptionErrorCode =
| 'invalid_key_length'
| 'encryption_not_supported_for_ephemeral'
| 'decrypt_failed';

/**
* Error thrown by sqlite-opfs when an encryption operation fails.
**/
export class SqliteEncryptionError extends Error {
readonly code: SqliteEncryptionErrorCode;

constructor(code: SqliteEncryptionErrorCode, message: string, opts?: { cause?: unknown }) {
super(message, opts?.cause !== undefined ? { cause: opts.cause } : undefined);
this.name = 'SqliteEncryptionError';
this.code = code;
}
}

/**
* Strings raised by sqlite3mc when page 1 cannot be decoded.
**/
const SQLITE3MC_DECRYPT_ERROR_PATTERNS: readonly RegExp[] = [
/file is not a database/i,
/file is encrypted or is not a database/i,
];

/**
* Returns `true` if `message` matches one of the known sqlite3mc decrypt-failure
* strings.
**/
export function isDecryptFailureMessage(message: string): boolean {
return SQLITE3MC_DECRYPT_ERROR_PATTERNS.some(p => p.test(message));
}
2 changes: 2 additions & 0 deletions yarn-project/kv-store/src/sqlite-opfs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { initStoreForRollupAndSchemaVersion } from '../utils.js';
import { AztecSQLiteOPFSStore } from './store.js';

export { AztecSQLiteOPFSStore } from './store.js';
export { SqliteEncryptionError } from './errors.js';
export type { SqliteEncryptionErrorCode } from './errors.js';

export async function createStore(
name: string,
Expand Down
13 changes: 12 additions & 1 deletion yarn-project/kv-store/src/sqlite-opfs/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* All requests carry a unique `id`; responses echo the same id so the main thread
* can resolve the right pending promise.
*/
import type { SqliteEncryptionErrorCode } from './errors.js';

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

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

export type WorkerRequestType = WorkerRequest['type'];
20 changes: 17 additions & 3 deletions yarn-project/kv-store/src/sqlite-opfs/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { AztecAsyncSet } from '../interfaces/set.js';
import type { AztecAsyncSingleton } from '../interfaces/singleton.js';
import type { AztecAsyncKVStore } from '../interfaces/store.js';
import { SQLiteOPFSAztecArray } from './array.js';
import { SqliteEncryptionError } from './errors.js';
import { SQLiteOPFSAztecMap } from './map.js';
import type { ResultRow, SqlValue, WorkerRequest, WorkerResponse } from './messages.js';
import { SQLiteOPFSAztecMultiMap } from './multi_map.js';
Expand Down Expand Up @@ -86,10 +87,16 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
encryptionKey?: Uint8Array,
): Promise<AztecSQLiteOPFSStore> {
if (encryptionKey !== undefined && encryptionKey.length !== 32) {
throw new Error(`encryptionKey must be 32 bytes (got ${encryptionKey.length})`);
throw new SqliteEncryptionError(
'invalid_key_length',
`encryptionKey must be 32 bytes (got ${encryptionKey.length})`,
);
}
if (encryptionKey !== undefined && ephemeral) {
throw new Error('encryptionKey is not supported for ephemeral (:memory:) stores');
throw new SqliteEncryptionError(
'encryption_not_supported_for_ephemeral',
'encryptionKey is not supported for ephemeral (:memory:) stores',
);
}
const dbName = name && !ephemeral ? name : `tmp-${globalThis.crypto.getRandomValues(new Uint8Array(8)).join('')}`;
log.debug(
Expand Down Expand Up @@ -265,7 +272,14 @@ export class AztecSQLiteOPFSStore implements AztecAsyncKVStore {
this.#pending.set(req.id, {
resolve: resp => {
if (resp.type === 'err') {
reject(new Error(resp.message));
// Re-hydrate encryption-shaped errors as the typed class so consumers
// can pattern-match on `instanceof SqliteEncryptionError`. Plain
// errors stay plain — the wire protocol only tags encryption paths.
if (resp.encryptionCode !== undefined) {
reject(new SqliteEncryptionError(resp.encryptionCode, resp.message));
} else {
reject(new Error(resp.message));
}
} else {
resolve(resp);
}
Expand Down
36 changes: 34 additions & 2 deletions yarn-project/kv-store/src/sqlite-opfs/worker.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/// <reference lib="webworker" />
import sqlite3InitModule, { type Database, type SAHPoolUtil, type Sqlite3Static } from '@aztec/sqlite3mc-wasm';

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

const SCHEMA_SQL = `
Expand Down Expand Up @@ -70,7 +71,10 @@ async function handleInit(
sqlite3 ??= await sqlite3InitModule();
const s = sqlite3;
if (encryptionKey !== undefined && ephemeral) {
throw new Error('encryptionKey is not supported for ephemeral (:memory:) stores');
throw new SqliteEncryptionError(
'encryption_not_supported_for_ephemeral',
'encryptionKey is not supported for ephemeral (:memory:) stores',
);
}
if (ephemeral) {
db = new s.oo1.DB(':memory:', 'c');
Expand Down Expand Up @@ -194,6 +198,34 @@ function respond(msg: WorkerResponse): void {
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
respond({ type: 'err', id: req.id, message });
respond({ type: 'err', id: req.id, message, encryptionCode: detectEncryptionCode(req, err, message) });
}
};

/**
* Maps a thrown error during request handling to a typed encryption code, so the
* main thread can re-hydrate it as a {@link SqliteEncryptionError}. Returns
* `undefined` for non-encryption errors (preserves the existing untyped path).
*
* Two sources:
* - The error was already a `SqliteEncryptionError` (pre-flight throws inside
* this worker — e.g. ephemeral + encryptionKey). Forward its code as-is.
* - The error came from SQLite/sqlite3mc with a known decrypt-failure message
* during an `init` request. Both "wrong key supplied" and "no key supplied
* to an encrypted DB" surface as SQLITE_NOTADB ("file is not a database") —
* we don't constrain on `req.encryptionKey` because the no-key-on-encrypted-DB
* case is exactly when the caller most needs the typed signal.
*/
function detectEncryptionCode(
req: WorkerRequest,
err: unknown,
message: string,
): SqliteEncryptionErrorCode | undefined {
if (err instanceof SqliteEncryptionError) {
return err.code;
}
if (req.type === 'init' && isDecryptFailureMessage(message)) {
return 'decrypt_failed';
}
return undefined;
}
1 change: 1 addition & 0 deletions yarn-project/wallets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"default": "./dest/embedded/entrypoints/node.js"
}
},
"./embedded/store-encryption": "./dest/embedded/store_encryption.js",
"./testing": "./dest/testing.js"
},
"scripts": {
Expand Down
7 changes: 7 additions & 0 deletions yarn-project/wallets/src/embedded/entrypoints/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,10 @@ export { BrowserEmbeddedWallet as EmbeddedWallet };
export type { EmbeddedWalletOptions, EmbeddedWalletPXEOptions } from '../embedded_wallet.js';
export { WalletDB } from '../wallet_db.js';
export type { AccountType } from '../wallet_db.js';

// At-rest encryption helpers are intentionally NOT re-exported here. They live
// on the `@aztec/wallets/embedded/store-encryption` sub-path so consumers
// (and bundlers) of this entrypoint don't transitively pull in
// `@aztec/kv-store/sqlite-opfs` and its `new Worker(new URL('./worker.js'))`
// chain into `@aztec/sqlite3mc-wasm`. Apps that don't use encryption-at-rest
// (e.g. the playground) should never see sqlite-opfs in their bundle.
Loading
Loading