Skip to content
Open
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
54 changes: 50 additions & 4 deletions src/services/walletService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ValueCipher> {
return AesGcmCipher.create(new RawKeyProvider(getOrCreateCipherSeed()));
}
import {
WalletManager,
type WalletProvider,
Expand Down Expand Up @@ -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();
Expand Down
52 changes: 52 additions & 0 deletions src/utils/sqliteInspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,51 @@ async function summarize(store: InspectableStore): Promise<Array<{ container: st
return rows.map(r => ({ 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;
Expand All @@ -49,6 +94,9 @@ export type SqliteInspectors = {
downloadWallet(): Promise<void>;
/** 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<ReturnType<typeof peek>>; wallet: Awaited<ReturnType<typeof peek>> }>;
};

/**
Expand All @@ -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;
}