mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
test: cover wallet lifecycle through the browser
This commit is contained in:
@@ -79,6 +79,97 @@ test.describe('Security page', () => {
|
|||||||
// auto-lock: display-only for now
|
// auto-lock: display-only for now
|
||||||
await expect(page.getByText('Locks after 5 minutes without activity')).toBeVisible();
|
await expect(page.getByText('Locks after 5 minutes without activity')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('lists only passkeys owned by the current wallet', async ({ page }) => {
|
||||||
|
// Given a browser with a PRF-capable authenticator probe and an unlocked wallet
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
Object.defineProperty(window, 'PublicKeyCredential', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
isUserVerifyingPlatformAuthenticatorAvailable: () => Promise.resolve(true),
|
||||||
|
getClientCapabilities: () => Promise.resolve({ 'extension:prf': true }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await createFreshWallet(page);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const saved: unknown = JSON.parse(localStorage.getItem('sattle_linking_key') ?? '{}');
|
||||||
|
if (
|
||||||
|
typeof saved !== 'object' ||
|
||||||
|
saved === null ||
|
||||||
|
!('ownerId' in saved) ||
|
||||||
|
typeof saved.ownerId !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error('expected saved wallet owner');
|
||||||
|
}
|
||||||
|
const wrap = {
|
||||||
|
hkdfSalt: '11'.repeat(16),
|
||||||
|
iv: '22'.repeat(12),
|
||||||
|
wrappedKey: '33'.repeat(48),
|
||||||
|
createdAt: 1,
|
||||||
|
};
|
||||||
|
localStorage.setItem(
|
||||||
|
'sattle_passkey_slots',
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
...wrap,
|
||||||
|
credentialId: '44'.repeat(16),
|
||||||
|
name: 'Current wallet passkey',
|
||||||
|
ownerId: saved.ownerId,
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...wrap,
|
||||||
|
credentialId: '55'.repeat(16),
|
||||||
|
name: 'Foreign wallet passkey',
|
||||||
|
ownerId: '0256b328b30c8bf5839e24058747879408bdb36241dc9c2e7c619faa12b2920967',
|
||||||
|
version: 1,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the security management surface reads passkey slots
|
||||||
|
await page.goto('/#/settings/security');
|
||||||
|
|
||||||
|
// Then only the current owner's slot is rendered
|
||||||
|
await expect(page.getByText('Current wallet passkey')).toBeVisible();
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the locked wallet renders
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Then passkey-first unlock is unavailable until another owner proof
|
||||||
|
await expect(page.getByText('Wallet locked')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Unlock with passkey' })).toHaveCount(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Welcome: restore from nostr', () => {
|
test.describe('Welcome: restore from nostr', () => {
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
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.
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
'sattle_linking_key',
|
||||||
|
'sattle_bearers',
|
||||||
|
'sattle_activity',
|
||||||
|
'sattle_settings',
|
||||||
|
'sattle_passkey_slots',
|
||||||
|
'sattle_nwc_connections',
|
||||||
|
'sattle_nwc_enabled',
|
||||||
|
'sattle_trusted_mints',
|
||||||
|
'sattle_biometric_wrap',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test.describe('wallet lifecycle', () => {
|
||||||
|
test('legacy unlock migrates, forget wipes the owner, 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 },
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
await expect(page.getByRole('button', { name: 'Get started' })).toBeVisible();
|
||||||
|
await expect
|
||||||
|
.poll(async () => page.evaluate(() => window.__nwcSubs.every((sub) => sub.closed)))
|
||||||
|
.toBe(true);
|
||||||
|
const remaining = await page.evaluate(
|
||||||
|
(keys) => keys.filter((key) => localStorage.getItem(key) !== null),
|
||||||
|
[...WALLET_KEYS],
|
||||||
|
);
|
||||||
|
expect(remaining).toEqual([]);
|
||||||
|
|
||||||
|
// When a successor wallet is created
|
||||||
|
await page.getByRole('button', { name: 'Get started' }).click();
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,15 @@ import { useWalletStore } from '@/stores/wallet';
|
|||||||
import { useNostrBackupStore } from '@/stores/nostrBackup';
|
import { useNostrBackupStore } from '@/stores/nostrBackup';
|
||||||
import { useNwcStore } from '@/stores/nwc';
|
import { useNwcStore } from '@/stores/nwc';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__sattleWalletTest?: {
|
||||||
|
readonly state: () => ReturnType<typeof useWalletStore>['state'];
|
||||||
|
readonly forget: () => Promise<void>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wallet lifecycle bootstrap: reflects whatever is on this device into the
|
// Wallet lifecycle bootstrap: reflects whatever is on this device into the
|
||||||
// wallet store at app start - a plaintext-stored key unlocks straight away,
|
// wallet store at app start - a plaintext-stored key unlocks straight away,
|
||||||
// a password-encrypted one lands on 'locked' for the unlock screen, and no
|
// a password-encrypted one lands on 'locked' for the unlock screen, and no
|
||||||
@@ -17,3 +26,12 @@ export default defineBoot(async () => {
|
|||||||
// client requests; on lock it stops and drops the key-material closure
|
// client requests; on lock it stops and drops the key-material closure
|
||||||
useNwcStore();
|
useNwcStore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// dev-only e2e hook: lets a spec observe the wallet state and drive the
|
||||||
|
// forget transition, which has no UI surface. Never in production builds.
|
||||||
|
if (import.meta.env.DEV && typeof window !== 'undefined') {
|
||||||
|
window.__sattleWalletTest = {
|
||||||
|
state: () => useWalletStore().state,
|
||||||
|
forget: () => useWalletStore().forgetWallet(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ const errorMessage = (err: unknown): string =>
|
|||||||
|
|
||||||
// ---- backup file ----
|
// ---- backup file ----
|
||||||
const downloadBackupFile = () => {
|
const downloadBackupFile = () => {
|
||||||
const backup = buildBackup();
|
const backup = buildBackup(wallet.pubkey ?? undefined);
|
||||||
const blob = new Blob([JSON.stringify(backup, null, 2)], {
|
const blob = new Blob([JSON.stringify(backup, null, 2)], {
|
||||||
type: 'application/json',
|
type: 'application/json',
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user