mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
229 lines
7.6 KiB
TypeScript
229 lines
7.6 KiB
TypeScript
import { bytesToHex } from '@noble/hashes/utils.js';
|
|
import { createPinia, setActivePinia } from 'pinia';
|
|
import { beforeEach, vi } from 'vitest';
|
|
|
|
import {
|
|
deriveWalletMaterial,
|
|
encryptSecretParts,
|
|
linkingPubKeyHex,
|
|
serializeWalletMaterial,
|
|
walletMaterialHash,
|
|
} from '@/lnurlcash/keys';
|
|
import type { WalletMaterialV2 } from '@/lnurlcash/keys';
|
|
import type * as NwcExports from '@/lnurlcash/nwc';
|
|
import type * as NostrBackupExports from '@/lnurlcash/nostrBackup';
|
|
import type * as PasskeysExports from '@/lnurlcash/passkeys';
|
|
import { WALLET_MATERIAL_STORAGE_KEY } from '@/lnurlcash/storage/walletOwnerEvents';
|
|
import { stubLocalStorage } from '@/lnurlcash/test-utils';
|
|
import { lifecycleMocks } from './wallet.lifecycle.testMocks';
|
|
|
|
export { lifecycleMocks as mocks } from './wallet.lifecycle.testMocks';
|
|
|
|
vi.mock('@/capabilities/biometricUnlock', async () => {
|
|
const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks');
|
|
return {
|
|
disableBiometricUnlock: lifecycleMocks.disableBiometricUnlock,
|
|
unlockWalletMaterialWithBiometrics: lifecycleMocks.unlockWalletMaterialWithBiometrics,
|
|
};
|
|
});
|
|
|
|
vi.mock('@/lnurlcash/passkeys', async (importOriginal) => {
|
|
const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks');
|
|
const actual = await importOriginal<typeof PasskeysExports>();
|
|
return {
|
|
...actual,
|
|
unlockWalletMaterialWithPasskey: lifecycleMocks.unlockWalletMaterialWithPasskey,
|
|
};
|
|
});
|
|
|
|
vi.mock('@/lnurlcash/nwc', async (importOriginal) => {
|
|
const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks');
|
|
const actual = await importOriginal<typeof NwcExports>();
|
|
return { ...actual, startService: lifecycleMocks.startService };
|
|
});
|
|
|
|
vi.mock('@/lnurlcash/nostrBackup', async (importOriginal) => {
|
|
const { lifecycleMocks } = await import('./wallet.lifecycle.testMocks');
|
|
const actual = await importOriginal<typeof NostrBackupExports>();
|
|
return { ...actual, restoreFromNostr: lifecycleMocks.restoreFromNostr };
|
|
});
|
|
|
|
export const LINKING_KEY = new Uint8Array(32).fill(7);
|
|
export const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
|
|
export const MATERIAL: WalletMaterialV2 = {
|
|
...deriveWalletMaterial(
|
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
|
|
),
|
|
linkingKeyHex: bytesToHex(LINKING_KEY),
|
|
};
|
|
export const OTHER_MATERIAL: WalletMaterialV2 = {
|
|
...MATERIAL,
|
|
linkingKeyHex: bytesToHex(OTHER_LINKING_KEY),
|
|
};
|
|
export const OWNER_ID = linkingPubKeyHex(LINKING_KEY);
|
|
export const OTHER_OWNER_ID = linkingPubKeyHex(OTHER_LINKING_KEY);
|
|
export const PASSWORD = 'correct horse battery staple';
|
|
export const MINT_KEY = '02' + 'aa'.repeat(32);
|
|
|
|
export const WALLET_MATERIAL_KEY = WALLET_MATERIAL_STORAGE_KEY;
|
|
export const LEGACY_LINKING_KEY = 'sattle_linking_key';
|
|
// the frozen pre-v2 bearer namespace: current storage never writes it, so
|
|
// its presence means an alpha install
|
|
export const LEGACY_BEARERS_KEY = 'sattle_bearers';
|
|
|
|
export const encryptedWalletMaterialRecord = async (
|
|
material: WalletMaterialV2 = MATERIAL,
|
|
password: string = PASSWORD,
|
|
) => {
|
|
const parts = await encryptSecretParts(serializeWalletMaterial(material), password);
|
|
return { enc: true as const, ...parts, materialHash: walletMaterialHash(material) };
|
|
};
|
|
|
|
// a v2 install whose owner marker is already proven (the steady state after
|
|
// any successful unlock)
|
|
export const installEncryptedWalletMaterial = async (): Promise<void> => {
|
|
const record = await encryptedWalletMaterialRecord();
|
|
localStorage.setItem(
|
|
WALLET_MATERIAL_KEY,
|
|
JSON.stringify({ ...record, ownerId: OWNER_ID, version: 2 }),
|
|
);
|
|
};
|
|
|
|
// a v2 install whose owner marker was stripped on restore - usable but not
|
|
// yet proven, the state a backup/relay restore leaves behind
|
|
export const installOwnerlessEncryptedWalletMaterial = async (): Promise<void> => {
|
|
localStorage.setItem(
|
|
WALLET_MATERIAL_KEY,
|
|
JSON.stringify(await encryptedWalletMaterialRecord()),
|
|
);
|
|
};
|
|
|
|
// the pre-v2 record shape a backup file can still carry - the lifecycle must
|
|
// never activate it, only clear it
|
|
export const encryptedLinkingKeyRecord = async () => {
|
|
const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD);
|
|
return { enc: true as const, ...parts };
|
|
};
|
|
|
|
// unsupported alpha state: the linking-key-only record and the legacy bearer
|
|
// namespace, never written by the v2 lifecycle
|
|
export const installLegacyWalletState = (): void => {
|
|
localStorage.setItem(
|
|
LEGACY_LINKING_KEY,
|
|
JSON.stringify({ enc: false, value: bytesToHex(LINKING_KEY), ownerId: OWNER_ID, version: 1 }),
|
|
);
|
|
localStorage.setItem(LEGACY_BEARERS_KEY, JSON.stringify([]));
|
|
};
|
|
|
|
type Deferred = {
|
|
readonly promise: Promise<void>;
|
|
readonly resolve: () => void;
|
|
};
|
|
|
|
export const deferred = (): Deferred => {
|
|
let resolvePromise: (() => void) | undefined;
|
|
const promise = new Promise<void>((resolve) => {
|
|
resolvePromise = resolve;
|
|
});
|
|
return {
|
|
promise,
|
|
resolve: () => resolvePromise?.(),
|
|
};
|
|
};
|
|
|
|
type LockRequest = {
|
|
readonly callback: () => unknown;
|
|
readonly resolve: (value: unknown) => void;
|
|
readonly reject: (error: unknown) => void;
|
|
};
|
|
|
|
// a navigator.locks stand-in whose requests queue up until the test releases
|
|
// them one at a time - the deferral hook for mid-activation interruptions.
|
|
// A callback failure rejects the lock promise (the real LockManager settles
|
|
// the caller's promise with the callback outcome)
|
|
export const deferredLocks = () => {
|
|
const requests: LockRequest[] = [];
|
|
return {
|
|
requests,
|
|
locks: {
|
|
request: (_name: string, callback: () => unknown): Promise<unknown> =>
|
|
new Promise((resolve, reject) => {
|
|
requests.push({ callback, resolve, reject });
|
|
}),
|
|
},
|
|
releaseNext: async (): Promise<void> => {
|
|
const request = requests.shift();
|
|
if (!request) throw new Error('Expected a queued lock request.');
|
|
try {
|
|
request.resolve(await request.callback());
|
|
} catch (error) {
|
|
request.reject(error);
|
|
}
|
|
},
|
|
};
|
|
};
|
|
|
|
export const installLegacyOwnerlessResidue = (): void => {
|
|
localStorage.setItem(
|
|
'sattle_passkey_slots',
|
|
JSON.stringify([
|
|
{
|
|
credentialId: '11'.repeat(16),
|
|
hkdfSalt: '22'.repeat(16),
|
|
iv: '33'.repeat(12),
|
|
materialHash: walletMaterialHash(MATERIAL),
|
|
wrappedMaterial: '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: MINT_KEY,
|
|
addedAt: 1,
|
|
locked: false,
|
|
},
|
|
]),
|
|
);
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.unstubAllGlobals();
|
|
stubLocalStorage();
|
|
setActivePinia(createPinia());
|
|
lifecycleMocks.disableBiometricUnlock.mockResolvedValue();
|
|
lifecycleMocks.unlockWalletMaterialWithBiometrics.mockRejectedValue(
|
|
new Error('Biometric unlock is not set up on this device.'),
|
|
);
|
|
lifecycleMocks.unlockWalletMaterialWithPasskey.mockRejectedValue(
|
|
new Error('No passkeys registered on this device.'),
|
|
);
|
|
lifecycleMocks.restoreFromNostr.mockResolvedValue({
|
|
added: 0,
|
|
skipped: 0,
|
|
linkingKeyRestored: false,
|
|
linkingKeySkipped: false,
|
|
trustedMintsAdded: 0,
|
|
settingsRestored: false,
|
|
found: [],
|
|
});
|
|
lifecycleMocks.startService.mockResolvedValue({ stop: vi.fn().mockResolvedValue(undefined) });
|
|
});
|