mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
test(e2e): cover signed BIP-32 wallet flows
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user