test(e2e): cover signed BIP-32 wallet flows

This commit is contained in:
2026-09-05 15:22:02 +02:00
parent 5d6e092ae4
commit 1beb474012
10 changed files with 777 additions and 310 deletions
+107 -31
View File
@@ -1,5 +1,9 @@
import { test, expect } from '../fixtures';
import { createFreshWallet } from '../helpers/wallet';
import { bytesToHex } from '@noble/hashes/utils.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { utf8ToBytes } from '@noble/hashes/utils.js';
import { secp256k1 } from '@noble/curves/secp256k1.js';
// M4 backup & security surfaces. No real WebAuthn: headless Chromium has no
// platform authenticator, so the security page is asserted in its honest
@@ -12,6 +16,72 @@ const VALID_PHRASE =
const readSettings = async (page: import('@playwright/test').Page) =>
JSON.parse((await page.evaluate(() => localStorage.getItem('sattle_settings'))) ?? '{}');
// A v2 encrypted wallet-material record built with the app's own wrap
// (keys.ts/passwordWrap.ts: PBKDF2-SHA256 210k -> AES-GCM), for specs that
// need a saved, locked wallet without driving the onboarding UI.
const PASSWORD = 'correct horse battery staple';
const PBKDF2_ITERATIONS = 210_000;
const encryptedWalletMaterialRecord = async (
password: string,
): Promise<{
enc: true;
salt: string;
iv: string;
ciphertext: string;
materialHash: string;
ownerId: string;
version: 2;
}> => {
const linkingKeyHex = '07'.repeat(32);
const material = {
version: 2,
linkingKeyHex,
cashRootHex: '08'.repeat(64),
};
const serialized = JSON.stringify(material);
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const baseKey = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveKey'],
);
const aesKey = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt'],
);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
new TextEncoder().encode(serialized),
),
);
return {
enc: true,
salt: bytesToHex(salt),
iv: bytesToHex(iv),
ciphertext: bytesToHex(ciphertext),
materialHash: bytesToHex(sha256(utf8ToBytes(serialized))),
ownerId: bytesToHex(secp256k1.getPublicKey(hexToBytesLocal(linkingKeyHex), true)),
version: 2,
};
};
const hexToBytesLocal = (hex: string): Uint8Array => {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
};
test.describe('Backup page', () => {
test('renders all sections and exports a JSON backup file', async ({ page }) => {
await createFreshWallet(page);
@@ -93,19 +163,22 @@ test.describe('Security page', () => {
});
await createFreshWallet(page);
await page.evaluate(() => {
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}');
const saved: unknown = JSON.parse(localStorage.getItem('sattle_wallet_material_v2') ?? '{}');
if (
typeof saved !== 'object' ||
saved === null ||
!('ownerId' in saved) ||
typeof saved.ownerId !== 'string'
typeof saved.ownerId !== 'string' ||
!('materialHash' in saved) ||
typeof saved.materialHash !== 'string'
) {
throw new Error('expected saved wallet owner');
}
const wrap = {
hkdfSalt: '11'.repeat(16),
iv: '22'.repeat(12),
wrappedKey: '33'.repeat(48),
materialHash: saved.materialHash,
wrappedMaterial: '33'.repeat(48),
createdAt: 1,
};
localStorage.setItem(
@@ -116,14 +189,14 @@ test.describe('Security page', () => {
credentialId: '44'.repeat(16),
name: 'Current wallet passkey',
ownerId: saved.ownerId,
version: 1,
version: 2,
},
{
...wrap,
credentialId: '55'.repeat(16),
name: 'Foreign wallet passkey',
ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967',
version: 1,
version: 2,
},
]),
);
@@ -137,36 +210,39 @@ test.describe('Security page', () => {
await expect(page.getByText('Foreign wallet passkey')).toHaveCount(0);
});
test('hides passkey-first unlock for a markerless saved wallet', async ({ page }) => {
// Given an encrypted saved key and passkey slot without owner markers
await page.addInitScript(() => {
localStorage.setItem(
'sattle_linking_key',
JSON.stringify({
enc: true,
salt: '11'.repeat(16),
iv: '22'.repeat(12),
ciphertext: '33'.repeat(48),
}),
);
localStorage.setItem(
'sattle_passkey_slots',
JSON.stringify([
{
credentialId: '44'.repeat(16),
hkdfSalt: '55'.repeat(16),
iv: '66'.repeat(12),
wrappedKey: '77'.repeat(48),
createdAt: 1,
},
]),
);
});
test('hides passkey-first unlock when no passkey belongs to the saved wallet', async ({
page,
}) => {
// Given an encrypted v2 saved wallet whose only passkey slot belongs to
// a DIFFERENT owner (a foreign wallet's residue on the same device)
const record = await encryptedWalletMaterialRecord(PASSWORD);
await page.addInitScript(
({ storedRecord }) => {
localStorage.setItem('sattle_wallet_material_v2', JSON.stringify(storedRecord));
localStorage.setItem(
'sattle_passkey_slots',
JSON.stringify([
{
credentialId: '44'.repeat(16),
hkdfSalt: '55'.repeat(16),
iv: '66'.repeat(12),
materialHash: '77'.repeat(32),
wrappedMaterial: '88'.repeat(48),
createdAt: 1,
ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967',
version: 2,
},
]),
);
},
{ storedRecord: record },
);
// When the locked wallet renders
await page.goto('/');
// Then passkey-first unlock is unavailable until another owner proof
// Then passkey-first unlock is unavailable - the one slot on this
// device is not this wallet's
await expect(page.getByText('Wallet locked')).toBeVisible();
await expect(page.getByRole('button', { name: 'Unlock with passkey' })).toHaveCount(0);
});
+3 -2
View File
@@ -38,6 +38,7 @@ const mockSuggestionMint = async (page: Page): Promise<void> => {
callback: 'https://mint.600.wtf/pay',
minSendable: 1000,
maxSendable: 100_000_000_000,
commentAllowed: 64,
withdrawLink: 'https://mint.600.wtf/note',
metadata: '[]',
}),
@@ -87,8 +88,8 @@ test.describe('Manage mints', () => {
test('a mint with held notes cannot be removed', async ({ page, mint }) => {
// hold a 21-sat note from the mock mint - holding funds locks the mint
// against removal
await mint.mockNoteInfo({ amountMsat: 21_000, mintPubkey: MINT_PUBKEY });
await mint.mockRotateOk();
await mint.mockNoteInfo({ amountMsat: 21_000 });
await mint.mockMutationEndpoint();
await createFreshWallet(page);
await page.getByRole('button', { name: 'Receive' }).click();
+6 -30
View File
@@ -1,13 +1,10 @@
import { test, expect } from '../fixtures';
import type { Page } from '@playwright/test';
import { buildNoteUrl, defaultRandomSecret } from 'lnurlcash-kit';
import { MINT_ORIGIN, MINT2_ORIGIN, NOTE_PATH } from '../helpers/MintMocker';
import { createFreshWallet } from '../helpers/wallet';
import { MINT2_ORIGIN } from '../helpers/MintMocker';
import { createFreshWallet, fundMockMintWallet } from '../helpers/wallet';
const AMOUNT_MSAT = 50_000; // 50 sats
const MINT_PUBKEY = `02${'ab'.repeat(32)}`;
const TARGET_PUBKEY = `03${'cd'.repeat(32)}`;
// 64 hex chars - a valid preimage (it becomes the claimed note's secret)
// 64 hex chars - a valid payment receipt, distinct from the claimed note secret
const PREIMAGE = 'ef'.repeat(32);
// amount-less by decodeBolt11AmountMsat, so the kit skips its invoice
// amount cross-check against the requested value
@@ -15,26 +12,7 @@ const INVOICE = 'lnmock1transfer';
// give the wallet a verified, spendable 50-sat note at the source mint
const fundSourceMint = async (page: Page, mint: import('../helpers/MintMocker').MintMocker) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY });
await mint.mockRotateOk();
await createFreshWallet(page);
await page.getByRole('button', { name: 'Receive' }).click();
const chooser = page.locator('.q-dialog', { hasText: 'Paste or scan a note' });
await chooser.getByRole('button', { name: 'Bearer note' }).click();
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
await dialog
.locator('textarea')
.fill(buildNoteUrl(`${MINT_ORIGIN}${NOTE_PATH}`, defaultRandomSecret(), AMOUNT_MSAT));
await dialog.getByRole('button', { name: 'Receive', exact: true }).click();
await expect(dialog.getByText('Received 50 sats')).toBeVisible();
// the note advertises a mint key, so the first-contact trust prompt opens
// as its own dialog - trust it (that also locks the mint)
await page
.locator('.q-dialog', { hasText: 'New mint' })
.getByRole('button', { name: 'Trust this mint' })
.click();
await dialog.getByRole('button', { name: 'Done' }).click();
await fundMockMintWallet(page, mint, AMOUNT_MSAT);
};
// pick an option from a Quasar select identified by its label
@@ -78,7 +56,6 @@ test.describe('Move funds', () => {
// the target mint advertises a flat 2-sat receive fee (2000 msat, 0 ppm)
await mint.mockTargetMint(
{
mintPubkey: TARGET_PUBKEY,
invoice: INVOICE,
preimage: PREIMAGE,
noteAmountMsat: AMOUNT_MSAT,
@@ -100,11 +77,10 @@ test.describe('Move funds', () => {
test('a two-mint transfer moves the balance', async ({ page, mint }) => {
await fundSourceMint(page, mint);
// the target mint: hands out the invoice, reports it settled with the
// preimage, and answers the claim's note info + rotation
// the target mint commits the wallet-chosen secret, hands out the invoice,
// and exposes that note only after the source-mint melt pays it
await mint.mockTargetMint(
{
mintPubkey: TARGET_PUBKEY,
invoice: INVOICE,
preimage: PREIMAGE,
noteAmountMsat: AMOUNT_MSAT,
+126 -1
View File
@@ -1,17 +1,34 @@
import { test, expect } from '../fixtures';
import { createFreshWallet } from '../helpers/wallet';
import { createFreshWallet, fundMockMintWallet } from '../helpers/wallet';
import { hexToBytes } from '@noble/hashes/utils.js';
import { finalizeEvent } from 'nostr-tools/pure';
import { decrypt as nip04Decrypt, encrypt as nip04Encrypt } from 'nostr-tools/nip04';
// M5 Nostr Wallet Connect settings surface. No real relay traffic: the one
// test that runs the service injects a fake transport through the dev-only
// window hook (stores/nwc.ts) before enabling - every other test stays
// UI-only with the service off.
// a Nostr event as the fake transport carries it - the plain JSON shape
// NIP-47 requests and responses take on the wire
type WireEvent = {
id: string;
pubkey: string;
kind: number;
created_at: number;
tags: string[][];
content: string;
sig: string;
};
type FakeSub = { closed: boolean; close: () => void };
declare global {
interface Window {
__sattleNwcTest: { setTransport: (transport: unknown) => void };
__nwcSubs: FakeSub[];
__nwcPublished: WireEvent[];
__nwcOnEvent?: (event: WireEvent) => void;
}
}
@@ -146,4 +163,112 @@ test.describe('NWC settings page', () => {
.poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed)))
.toBe(true);
});
test('a client pays an invoice and the wallet settles it at the mint', async ({ page, mint }) => {
// a funded wallet: 50 sats at the mock mint, its signing key pinned
await fundMockMintWallet(page, mint, 50_000);
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
// create a connection and read its one-time URI: the wallet service
// pubkey is the host, the client secret the `secret` param
await openNwcPage(page);
await page.getByRole('button', { name: 'Create connection' }).click();
const uri = (await page.locator('.nwc-connection-string').textContent())?.trim();
expect(uri).toBeTruthy();
if (!uri) throw new Error('Expected the one-time NWC connection string.');
await page.getByRole('button', { name: "Done - I've saved it" }).click();
const connectionUrl = new URL(uri);
const walletServicePubkey = connectionUrl.host;
const clientSecret = connectionUrl.searchParams.get('secret') ?? '';
// a fake transport that captures the request handler and every
// published event, installed before the service starts
await page.evaluate(() => {
window.__nwcSubs = [];
window.__nwcPublished = [];
window.__sattleNwcTest.setTransport({
publish: (_relays: string[], event: WireEvent) => {
window.__nwcPublished.push(event);
return Promise.resolve();
},
subscribe: (_relays: string[], _filter: unknown, onEvent: (event: WireEvent) => void) => {
window.__nwcOnEvent = onEvent;
const sub: FakeSub = {
closed: false,
close() {
this.closed = true;
},
};
window.__nwcSubs.push(sub);
return sub;
},
});
});
await page.locator('.q-toggle[aria-label="Enable Nostr Wallet Connect"]').click();
await expect(page.getByText('Service running', { exact: false })).toBeVisible();
await expect.poll(async () => page.evaluate(() => typeof window.__nwcOnEvent)).toBe('function');
// a 21-sat invoice the mint will settle; the request is exactly what a
// NIP-47 client sends: kind 23194, NIP-04 encrypted to the service,
// schnorr-signed by the connection's client secret
const invoice = 'lnbc210n1pjqrstuvwxyz';
const content = await nip04Encrypt(
clientSecret,
walletServicePubkey,
JSON.stringify({ method: 'pay_invoice', params: { invoice } }),
);
const request = finalizeEvent(
{
kind: 23194,
created_at: Math.floor(Date.now() / 1000),
tags: [
['p', walletServicePubkey],
['encryption', 'nip04'],
],
content,
},
hexToBytes(clientSecret),
);
await page.evaluate((event) => window.__nwcOnEvent?.(event), request);
// the service pays and answers on the same scheme
await expect
.poll(async () =>
page.evaluate(
(id) =>
window.__nwcPublished.find(
(event) =>
event.kind === 23195 && event.tags.some((tag) => tag[0] === 'e' && tag[1] === id),
) ?? null,
request.id,
),
)
.not.toBeNull();
const responseEvent = await page.evaluate(
(id) =>
window.__nwcPublished.find(
(event) =>
event.kind === 23195 && event.tags.some((tag) => tag[0] === 'e' && tag[1] === id),
) ?? null,
request.id,
);
if (!responseEvent) throw new Error('the service published no response');
const response: unknown = JSON.parse(
await nip04Decrypt(clientSecret, walletServicePubkey, responseEvent.content),
);
if (typeof response !== 'object' || response === null) {
throw new Error('the service response is not an object');
}
const result = response as { result_type?: unknown; error?: unknown; result?: unknown };
expect(result.result_type).toBe('pay_invoice');
expect(result.error).toBeNull();
// the receipt is the melt's payment preimage from the mint's own
// settle proof - 64 hex chars, never a note secret
const payload = result.result as { preimage?: unknown };
expect(payload.preimage).toMatch(/^[0-9a-f]{64}$/);
// the wallet paid 21 of its 50 sats; the change stays in the wallet
await page.goto('/#/');
await expect(page.locator('.balance-card .text-h2')).toHaveText('29');
});
});
+17 -6
View File
@@ -5,7 +5,6 @@ import { MINT_ORIGIN, NOTE_PATH } from '../helpers/MintMocker';
import { createFreshWallet } from '../helpers/wallet';
const AMOUNT_MSAT = 21_000; // 21 sats
const MINT_PUBKEY = `02${'aa'.repeat(32)}`;
// a syntactically valid bearer note against the mock mint - the k1 is a
// fresh random secret, so every test redeems a distinct note
@@ -26,7 +25,7 @@ const redeemNote = async (page: Page, noteUrl: string): Promise<void> => {
test.describe('Receive bearer note', () => {
test('redeeming a valid note updates the balance', async ({ page, mint }) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
await mint.mockRotateOk();
await mint.mockMutationEndpoint();
await createFreshWallet(page);
const dialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
@@ -38,6 +37,18 @@ test.describe('Receive bearer note', () => {
// were really consumed (the kit bug this guards against silently fell
// back to the declared amount without any request)
await expect(dialog.locator('.q-banner')).toHaveCount(0);
// the mint signed the rotation's output (LUD-25) - the wallet's
// requireSignatures policy accepted a signed answer, not a rescue
expect(
mint.signaturesIssued.some(
(entry) => entry.origin === MINT_ORIGIN && entry.amountMsat === AMOUNT_MSAT,
),
).toBe(true);
// the note advertised the mint's key, so the first-contact trust prompt
// opened - dismiss it, then close the success screen
const trustDialog = page.locator('.q-dialog', { hasText: 'New mint' });
await trustDialog.getByRole('button', { name: 'Just this once' }).click();
await dialog.getByRole('button', { name: 'Done' }).click();
await expect(page.locator('.balance-card .text-h2')).toHaveText('21');
});
@@ -64,8 +75,8 @@ test.describe('Receive bearer note', () => {
page,
mint,
}) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY });
await mint.mockRotateOk();
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
await mint.mockMutationEndpoint();
await createFreshWallet(page);
const receiveDialog = page.locator('.q-dialog', { hasText: 'Receive bearer note' });
await redeemNote(page, freshNoteUrl());
@@ -87,8 +98,8 @@ test.describe('Receive bearer note', () => {
page,
mint,
}) => {
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT, mintPubkey: MINT_PUBKEY });
await mint.mockRotateOk();
await mint.mockNoteInfo({ amountMsat: AMOUNT_MSAT });
await mint.mockMutationEndpoint();
await createFreshWallet(page);
await page.evaluate(() => {
localStorage.setItem('sattle_trusted_mints', '{"version":1,"ownerId":"malformed"}');
+114
View File
@@ -0,0 +1,114 @@
import { test, expect } from '../fixtures';
import type { Page } from '@playwright/test';
import {
fromBech32Lnurl,
noteDeclaredAmount,
noteK1,
noteSignature,
verifyNoteSignature,
} from 'lnurlcash-kit';
import { MINT_ORIGIN } from '../helpers/MintMocker';
import { fundMockMintWallet } from '../helpers/wallet';
const FUNDING_MSAT = 50_000; // 50 sats
const SEND_MSAT = 21_000; // 21 sats
// give the wallet a verified, spendable 50-sat note at the mock mint, with
// the mint's signing key pinned through the first-contact trust prompt
const fundMockMint = async (page: Page, mint: import('../helpers/MintMocker').MintMocker) => {
await fundMockMintWallet(page, mint, FUNDING_MSAT);
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
};
// prepare a note hand-over and read the displayed note off the clipboard
const prepareNote = async (page: Page, sats: string): Promise<string> => {
await page.getByRole('button', { name: 'Send' }).click();
const chooser = page.locator('.q-dialog', { hasText: 'Hand someone a note' });
await chooser.getByRole('button', { name: 'Bearer note' }).click();
const dialog = page.locator('.q-dialog', { hasText: 'Send a note' });
await dialog.getByLabel('Amount (sats)').fill(sats);
await dialog.getByRole('button', { name: 'Prepare note' }).click();
await expect(dialog.getByText('Tap to reveal')).toBeVisible();
await dialog.getByRole('button', { name: 'Copy' }).click();
return page.evaluate(() => navigator.clipboard.readText());
};
test.describe('Send a note', () => {
test('a carved note carries a signature that verifies against the mint', async ({
page,
mint,
}) => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await fundMockMint(page, mint);
// the mint signed the split's two outputs (the carved note and the
// change), and the note the dialog hands over carries that signature
// in its URL
const beforeSend = mint.signaturesIssued.length;
const bech32 = await prepareNote(page, '21');
const issued = mint.signaturesIssued
.slice(beforeSend)
.map((entry) => entry.amountMsat)
.sort((a, b) => a - b);
expect(issued).toEqual([SEND_MSAT, FUNDING_MSAT - SEND_MSAT]);
const url = fromBech32Lnurl(bech32.trim());
expect(url).not.toBeNull();
if (!url) throw new Error('the dialog handed over an undecodable note');
const k1 = noteK1(url);
const amountMsat = noteDeclaredAmount(url);
const signature = noteSignature(url);
expect(k1).not.toBeNull();
expect(amountMsat).toBe(SEND_MSAT);
expect(signature).not.toBeNull();
if (!k1 || amountMsat === null || !signature) throw new Error('the note lacks its sig param');
// the offline proof verifies against the key the mint advertised and
// the wallet pinned - this is what marked the carved note spendable
expect(verifyNoteSignature(k1, amountMsat, signature, mint.signingPubkey())).toBe(true);
// keep the note: nothing was handed over, the balance is untouched
const dialog = page.locator('.q-dialog', { hasText: 'Send a note' });
await dialog.getByRole('button', { name: 'Keep in wallet' }).click();
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
});
test('outputs the mint signs with a foreign key never become spendable', async ({
page,
mint,
}) => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await fundMockMint(page, mint);
// the mint's mutations land but are signed by a key it never advertised
mint.invalidSignatures = true;
const bech32 = await prepareNote(page, '21');
// the note exists (the split landed mint-side), but its offline proof
// verifies against no key this mint publishes
const url = fromBech32Lnurl(bech32.trim());
if (!url) throw new Error('the dialog handed over an undecodable note');
const k1 = noteK1(url);
const amountMsat = noteDeclaredAmount(url);
const signature = noteSignature(url);
if (!k1 || amountMsat === null || !signature) throw new Error('the note lacks its sig param');
expect(verifyNoteSignature(k1, amountMsat, signature, mint.signingPubkey())).toBe(false);
// keep the note: the balance is not corrupted by the bad proof
const sendDialog = page.locator('.q-dialog', { hasText: 'Send a note' });
await sendDialog.getByRole('button', { name: 'Keep in wallet' }).click();
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
// the carve's outputs stayed unverified, so the wallet refuses to spend
// from them - a second preparation fails instead of moving bad notes
await page.getByRole('button', { name: 'Send' }).click();
const chooser = page.locator('.q-dialog', { hasText: 'Hand someone a note' });
await chooser.getByRole('button', { name: 'Bearer note' }).click();
const retry = page.locator('.q-dialog', { hasText: 'Send a note' });
await retry.getByLabel('Amount (sats)').fill('21');
await retry.getByRole('button', { name: 'Prepare note' }).click();
await expect(
retry.locator('.q-banner', { hasText: 'Not enough spendable balance' }),
).toBeVisible();
await expect(retry.getByText('Tap to reveal')).toHaveCount(0);
await expect(page.locator('.balance-card .text-h2')).toHaveText('50');
});
});
+1 -1
View File
@@ -23,7 +23,7 @@ const addMint = async (page: Page, server: string, mintPubkey: string): Promise<
const ownerId = async (page: Page): Promise<string | null> =>
page.evaluate(() => {
const raw = localStorage.getItem('sattle_linking_key');
const raw = localStorage.getItem('sattle_wallet_material_v2');
if (raw === null) return null;
const saved: unknown = JSON.parse(raw);
if (typeof saved !== 'object' || saved === null || !('ownerId' in saved)) return null;
+89 -210
View File
@@ -1,215 +1,95 @@
import { bytesToHex } from '@noble/hashes/utils.js';
import { secp256k1 } from '@noble/curves/secp256k1.js';
import { test, expect } from '../fixtures';
// Wallet lifecycle in the real browser: a LEGACY (ownerless) encrypted wallet
// with ownerless passkey/NWC/trust residue must migrate to the proven owner
// during password unlock - before the NWC service may start - and forgetting
// the wallet must drain/stop that service and remove every wallet-owned key
// before a successor wallet can be created without any of the old residue.
//
// The legacy encrypted record is produced here with the exact same KDF/wrap
// the app uses (keys.ts: PBKDF2-SHA256 210k -> AES-GCM), just without the
// owner marker a current build would stamp.
// Wallet lifecycle in the real browser: an unsupported LEGACY (alpha)
// install - the linking-key-only record, the pre-v2 bearer store and their
// ownerless residue - is never migrated (the cash root is unrecoverable
// from a linking key), so boot resets the device to the uninstalled state
// before any route can expose it, and a successor wallet starts without
// any of the old residue.
declare global {
interface Window {
__sattleWalletTest: { state: () => string; forget: () => Promise<void> };
}
}
const PASSWORD = 'correct horse battery staple';
const LINKING_KEY_HEX = '07'.repeat(32);
const OWNER_ID = bytesToHex(secp256k1.getPublicKey(hexToBytesLocal(LINKING_KEY_HEX), true));
const MINT_PUBKEY = '02' + 'aa'.repeat(32);
function hexToBytesLocal(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i += 1) {
bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return bytes;
}
const PBKDF2_ITERATIONS = 210_000;
const legacyEncryptedRecord = async (
valueHex: string,
password: string,
): Promise<{ salt: string; iv: string; ciphertext: string }> => {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const baseKey = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(password),
'PBKDF2',
false,
['deriveKey'],
);
const aesKey = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt'],
);
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
new TextEncoder().encode(valueHex),
),
);
return { salt: bytesToHex(salt), iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext) };
};
const WALLET_KEYS = [
// every namespace the alpha wallet could have touched
const LEGACY_KEYS = [
'sattle_linking_key',
'sattle_bearers',
'sattle_activity',
'sattle_settings',
'sattle_wallet_material_v2',
'sattle_passkey_slots',
'sattle_nwc_connections',
'sattle_nwc_enabled',
'sattle_trusted_mints',
'sattle_biometric_wrap',
'sattle_activity',
'sattle_settings',
] as const;
test.describe('wallet lifecycle', () => {
test('legacy unlock migrates, forget wipes the owner, successor starts clean', async ({
test('a legacy alpha install is reset at boot and the successor starts clean', async ({
page,
}) => {
// Given a legacy encrypted wallet (no owner marker) plus ownerless residue
const record = await legacyEncryptedRecord(LINKING_KEY_HEX, PASSWORD);
await page.addInitScript(
({ storedRecord, mintPubkey }) => {
localStorage.setItem('sattle_linking_key', JSON.stringify({ enc: true, ...storedRecord }));
localStorage.setItem(
'sattle_passkey_slots',
JSON.stringify([
{
credentialId: '11'.repeat(16),
hkdfSalt: '22'.repeat(16),
iv: '33'.repeat(12),
wrappedKey: '44'.repeat(48),
createdAt: 1,
},
]),
);
localStorage.setItem(
'sattle_nwc_connections',
JSON.stringify([
{
clientPubkey: '55'.repeat(32),
relays: ['wss://relay.example'],
budget: { maxMsat: 1000, periodMs: 60_000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 1,
},
]),
);
localStorage.setItem('sattle_nwc_enabled', 'true');
localStorage.setItem(
'sattle_trusted_mints',
JSON.stringify([{ server: 'legacy.example', mintPubkey, addedAt: 1, locked: false }]),
);
},
{ storedRecord: record, mintPubkey: MINT_PUBKEY },
);
// Given a legacy encrypted linking-key record plus ownerless residue
await page.addInitScript(() => {
localStorage.setItem(
'sattle_linking_key',
JSON.stringify({
enc: true,
salt: '11'.repeat(16),
iv: '22'.repeat(12),
ciphertext: '33'.repeat(48),
}),
);
localStorage.setItem(
'sattle_bearers',
JSON.stringify([{ url: 'https://mint.example/note?k1=legacy', amount: 1000 }]),
);
localStorage.setItem(
'sattle_passkey_slots',
JSON.stringify([
{
credentialId: '11'.repeat(16),
hkdfSalt: '22'.repeat(16),
iv: '33'.repeat(12),
wrappedKey: '44'.repeat(48),
createdAt: 1,
},
]),
);
localStorage.setItem(
'sattle_nwc_connections',
JSON.stringify([
{
clientPubkey: '55'.repeat(32),
relays: ['wss://relay.example'],
budget: { maxMsat: 1000, periodMs: 60_000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 1,
},
]),
);
localStorage.setItem('sattle_nwc_enabled', 'true');
localStorage.setItem(
'sattle_trusted_mints',
JSON.stringify([
{
server: 'legacy.example',
mintPubkey: `02${'aa'.repeat(32)}`,
addedAt: 1,
locked: false,
},
]),
);
});
// When the app boots
await page.goto('/#/');
// the unlock screen proves the app boot finished and the dev hooks exist
await expect(page.getByText('Wallet locked')).toBeVisible();
await expect
.poll(async () => page.evaluate(() => typeof window.__sattleNwcTest))
.toBe('object');
// no real relay traffic once the migrated enabled state starts the service
await page.evaluate(() => {
window.__nwcSubs = [];
window.__sattleNwcTest.setTransport({
publish: () => Promise.resolve(),
subscribe: () => {
const sub = {
closed: false,
close() {
this.closed = true;
},
};
window.__nwcSubs.push(sub);
return sub;
},
});
});
// When the holder proves the wallet by password
await page.locator('.unlock-card input').fill(PASSWORD);
await page.getByRole('button', { name: 'Unlock', exact: true }).click();
// Then the wallet unlocks...
await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible();
// ...and every legacy namespace was migrated to the proven owner BEFORE
// the NWC service could start (the subscriptions below only exist because
// the migrated owner-scoped enabled record read true)
const migrated = await page.evaluate(() => {
const field = (storageKey: string, name: string): unknown => {
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
return typeof value === 'object' && value !== null
? Object.entries(value).find(([key]) => key === name)?.[1]
: undefined;
};
const fields = (storageKey: string, name: string): unknown[] => {
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? '[]');
if (!Array.isArray(value)) return [];
return value.map((entry: unknown) =>
typeof entry === 'object' && entry !== null
? Object.entries(entry).find(([key]) => key === name)?.[1]
: undefined,
);
};
const mints: unknown = field('sattle_trusted_mints', 'mints');
return {
savedKeyOwner: field('sattle_linking_key', 'ownerId'),
passkeyOwners: fields('sattle_passkey_slots', 'ownerId'),
nwcEnabledOwner: field('sattle_nwc_enabled', 'ownerId'),
nwcEnabled: field('sattle_nwc_enabled', 'enabled'),
nwcConnectionOwners: fields('sattle_nwc_connections', 'ownerId'),
trustedMintsOwner: field('sattle_trusted_mints', 'ownerId'),
trustedMintServers: Array.isArray(mints)
? mints.map((mint: unknown) =>
typeof mint === 'object' && mint !== null && 'server' in mint
? mint.server
: undefined,
)
: [],
};
});
expect(migrated.savedKeyOwner).toBe(OWNER_ID);
expect(migrated.passkeyOwners).toEqual([OWNER_ID]);
expect({ ownerId: migrated.nwcEnabledOwner, enabled: migrated.nwcEnabled }).toEqual({
ownerId: OWNER_ID,
enabled: true,
});
expect(migrated.nwcConnectionOwners).toEqual([OWNER_ID]);
expect(migrated.trustedMintsOwner).toBe(OWNER_ID);
expect(migrated.trustedMintServers).toEqual(['legacy.example']);
await expect.poll(async () => page.evaluate(() => window.__nwcSubs.length)).toBeGreaterThan(0);
// When the wallet is forgotten
await page.evaluate(() => window.__sattleWalletTest.forget());
// Then the app lands on the no-wallet screen, the service is drained
// (every subscription closed), and no wallet-owned key remains
// Then the alpha reset landed on the uninstalled state - no unlock
// surface for a wallet that can never be derived again
await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible();
await expect
.poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed)))
.toBe(true);
await expect(page.getByText('Wallet locked')).toHaveCount(0);
// ...and every namespace the alpha wallet could have touched is gone
const remaining = await page.evaluate(
(keys) => keys.filter((key) => localStorage.getItem(key) !== null),
[...WALLET_KEYS],
[...LEGACY_KEYS],
);
expect(remaining).toEqual([]);
@@ -218,25 +98,24 @@ test.describe('wallet lifecycle', () => {
await page.getByRole('button', { name: 'Create wallet' }).click();
await page.locator('.q-checkbox', { hasText: 'I wrote it down' }).click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible();
// Then it is a different owner with zero adopted residue
const successor = await page.evaluate(() => {
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? 'null');
return {
ownerId:
typeof saved === 'object' && saved !== null && 'ownerId' in saved
? saved.ownerId
: undefined,
residueKeys: [
'sattle_passkey_slots',
'sattle_nwc_connections',
'sattle_nwc_enabled',
'sattle_trusted_mints',
].filter((key) => localStorage.getItem(key) !== null),
};
});
expect(successor.ownerId).not.toBe(OWNER_ID);
expect(successor.residueKeys).toEqual([]);
// Then it unlocks into a working wallet with zero adopted residue
// (settings/activity are the live app's own namespaces - the residue
// check covers only the stores a predecessor could have planted)
await expect(page.getByRole('button', { name: 'Receive' })).toBeVisible();
await expect(page.locator('.balance-card .text-h2')).toHaveText('0');
const residue = await page.evaluate(
(keys) => keys.filter((key) => localStorage.getItem(key) !== null),
[
'sattle_linking_key',
'sattle_bearers',
'sattle_passkey_slots',
'sattle_nwc_connections',
'sattle_nwc_enabled',
'sattle_trusted_mints',
'sattle_biometric_wrap',
],
);
expect(residue).toEqual([]);
});
});