mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: activate only proven wallet owners
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
OWNER_ID,
|
||||
PASSWORD,
|
||||
deferred,
|
||||
installLegacyEncryptedWallet,
|
||||
installLegacyOwnerlessResidue,
|
||||
mocks,
|
||||
} from './wallet.lifecycle.testHarness';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { savedKeyExists, savedKeyOwnerId } from '@/lnurlcash/keys';
|
||||
import { readNwcConnections } from '@/lnurlcash/nwc';
|
||||
import { readPasskeySlots } from '@/lnurlcash/passkeys';
|
||||
import { readTrustedMints } from '@/lnurlcash/trustedMints';
|
||||
import { useNwcStore } from './nwc';
|
||||
import { useWalletStore } from './wallet';
|
||||
|
||||
describe('serialized wallet activation', () => {
|
||||
it('migrates a proven legacy owner before NWC can observe unlocked', async () => {
|
||||
// Given an encrypted legacy wallet with ownerless authorization residue
|
||||
await installLegacyEncryptedWallet();
|
||||
installLegacyOwnerlessResidue();
|
||||
const wallet = useWalletStore();
|
||||
useNwcStore();
|
||||
|
||||
// When password proof unlocks the wallet
|
||||
await wallet.unlock(PASSWORD);
|
||||
|
||||
// Then every legacy namespace belongs to the proven owner before startup
|
||||
expect(savedKeyOwnerId()).toBe(OWNER_ID);
|
||||
expect(readPasskeySlots()).toHaveLength(1);
|
||||
expect(readNwcConnections(OWNER_ID)).toHaveLength(1);
|
||||
expect(readTrustedMints(OWNER_ID)).toHaveLength(1);
|
||||
expect(wallet.state).toBe('unlocked');
|
||||
await vi.waitFor(() => expect(mocks.startService).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('serializes a queued create behind an interrupted forget', async () => {
|
||||
// Given an unlocked wallet whose NWC drain is deferred
|
||||
const wallet = useWalletStore();
|
||||
const nwc = useNwcStore();
|
||||
await wallet.create(PASSWORD);
|
||||
const drain = deferred();
|
||||
const stopSpy = vi.spyOn(nwc, 'stop').mockReturnValue(drain.promise);
|
||||
|
||||
// When forget and create are requested without waiting between them
|
||||
const forgetting = wallet.forgetWallet();
|
||||
const creating = wallet.create();
|
||||
await vi.waitFor(() => expect(stopSpy).toHaveBeenCalled());
|
||||
|
||||
// Then the successor cannot install until the old owner drain completes,
|
||||
// and the session keeps its commit capability while the drain runs
|
||||
expect(savedKeyExists()).toBe(true);
|
||||
expect(wallet.state).toBe('unlocked');
|
||||
drain.resolve();
|
||||
await forgetting;
|
||||
const phrase = await creating;
|
||||
expect(phrase.split(' ')).toHaveLength(12);
|
||||
expect(wallet.state).toBe('unlocked');
|
||||
});
|
||||
|
||||
it('drains the active session before file restore reactivation', async () => {
|
||||
// Given an unlocked wallet whose NWC drain is deferred
|
||||
const wallet = useWalletStore();
|
||||
const nwc = useNwcStore();
|
||||
await wallet.create(PASSWORD);
|
||||
const drain = deferred();
|
||||
const stopSpy = vi.spyOn(nwc, 'stop').mockReturnValue(drain.promise);
|
||||
|
||||
// When a valid file restore starts
|
||||
const restoring = wallet.restoreFromBackup({
|
||||
type: 'sattle-backup',
|
||||
version: 1,
|
||||
createdAt: 1,
|
||||
bearers: [],
|
||||
});
|
||||
await vi.waitFor(() => expect(stopSpy).toHaveBeenCalled());
|
||||
|
||||
// Then deactivation waits for the drain before the lifecycle is
|
||||
// invalidated, and reactivation returns to unlocked afterward
|
||||
expect(wallet.state).toBe('unlocked');
|
||||
drain.resolve();
|
||||
await restoring;
|
||||
expect(wallet.state).toBe('unlocked');
|
||||
});
|
||||
|
||||
it('serializes current-wallet Nostr restore through full reactivation', async () => {
|
||||
// Given an unlocked wallet and a relay restore result
|
||||
const wallet = useWalletStore();
|
||||
useNwcStore();
|
||||
await wallet.create(PASSWORD);
|
||||
const ownerId = wallet.pubkey;
|
||||
|
||||
// When the active-wallet Nostr restore runs
|
||||
await wallet.restoreCurrentFromNostr(['wss://relay.example']);
|
||||
|
||||
// Then the same owner is active only after the restore completed
|
||||
expect(mocks.restoreFromNostr).toHaveBeenCalledTimes(1);
|
||||
expect(wallet.state).toBe('unlocked');
|
||||
expect(wallet.pubkey).toBe(ownerId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
MINT_KEY,
|
||||
OTHER_OWNER_ID,
|
||||
OWNER_ID,
|
||||
PASSWORD,
|
||||
encryptedLinkingKeyRecord,
|
||||
} from './wallet.lifecycle.testHarness';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { savedKeyOwnerId } from '@/lnurlcash/keys';
|
||||
import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints';
|
||||
import { useNwcStore } from './nwc';
|
||||
import { useWalletStore } from './wallet';
|
||||
|
||||
describe('hostile file backup owner', () => {
|
||||
it('drops file trust until the restored encrypted key proves its actual owner', async () => {
|
||||
// Given a fresh device and an encrypted backup whose valid owner claim is foreign
|
||||
const wallet = useWalletStore();
|
||||
useNwcStore();
|
||||
const result = await wallet.restoreFromBackup({
|
||||
type: 'sattle-backup',
|
||||
version: 1,
|
||||
createdAt: 1,
|
||||
ownerId: OTHER_OWNER_ID,
|
||||
linkingKey: { ...(await encryptedLinkingKeyRecord()), ownerId: OTHER_OWNER_ID },
|
||||
bearers: [],
|
||||
trustedMints: [
|
||||
{
|
||||
server: 'file-mint.example',
|
||||
mintPubkey: MINT_KEY,
|
||||
addedAt: 1,
|
||||
locked: true,
|
||||
pendingMintPubkey: '03' + 'bb'.repeat(32),
|
||||
},
|
||||
],
|
||||
});
|
||||
const registryBeforeProof: unknown = JSON.parse(
|
||||
localStorage.getItem('sattle_trusted_mints') ?? 'null',
|
||||
);
|
||||
expect(result).toMatchObject({ linkingKeyRestored: true, trustedMintsAdded: 0 });
|
||||
expect(savedKeyOwnerId()).toBeNull();
|
||||
expect(registryBeforeProof).toBeNull();
|
||||
|
||||
// When password proof activates the restored linking key
|
||||
await expect(wallet.unlock(PASSWORD)).resolves.toBeUndefined();
|
||||
|
||||
// Then the file claim left no residue and only the derived owner can initialize trust
|
||||
expect(readTrustedMints(OTHER_OWNER_ID)).toEqual([]);
|
||||
expect(readTrustedMints(OWNER_ID)).toEqual([]);
|
||||
await expect(
|
||||
addTrustedMint('actual-owner.example', '03' + 'cc'.repeat(32), { ownerId: OWNER_ID }),
|
||||
).resolves.toBe('added');
|
||||
expect(JSON.parse(localStorage.getItem('sattle_trusted_mints') ?? 'null')).toEqual(
|
||||
expect.objectContaining({ ownerId: OWNER_ID }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
MINT_KEY,
|
||||
OTHER_OWNER_ID,
|
||||
PASSWORD,
|
||||
installLegacyOwnerlessResidue,
|
||||
} from './wallet.lifecycle.testHarness';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { generateSeedPhrase, savedKeyExists } from '@/lnurlcash/keys';
|
||||
import { readNwcConnections, readNwcEnabled } from '@/lnurlcash/nwc';
|
||||
import { readPasskeySlots } from '@/lnurlcash/passkeys';
|
||||
import { addTrustedMint, readTrustedMints } from '@/lnurlcash/trustedMints';
|
||||
import { useNwcStore } from './nwc';
|
||||
import { useWalletStore } from './wallet';
|
||||
|
||||
describe('foreign wallet isolation', () => {
|
||||
it('clears ownerless residue and the old trust tombstone before creating a successor', async () => {
|
||||
// Given ownerless legacy authorization plus a prior owner's trust tombstone
|
||||
installLegacyOwnerlessResidue();
|
||||
localStorage.setItem(
|
||||
'sattle_trusted_mints',
|
||||
JSON.stringify({ version: 1, ownerId: OTHER_OWNER_ID, mints: [] }),
|
||||
);
|
||||
const wallet = useWalletStore();
|
||||
useNwcStore();
|
||||
|
||||
// When a new wallet is installed
|
||||
await wallet.create();
|
||||
|
||||
// Then it starts without residue and can initialize its own trust registry
|
||||
expect(wallet.pubkey).not.toBe(OTHER_OWNER_ID);
|
||||
expect(readPasskeySlots()).toEqual([]);
|
||||
expect(readNwcConnections(wallet.pubkey)).toEqual([]);
|
||||
expect(readNwcEnabled(wallet.pubkey)).toBe(false);
|
||||
expect(readTrustedMints(wallet.pubkey ?? undefined)).toEqual([]);
|
||||
await expect(
|
||||
addTrustedMint('successor.example', MINT_KEY, { ownerId: wallet.pubkey ?? '' }),
|
||||
).resolves.toBe('added');
|
||||
});
|
||||
|
||||
it('rejects malformed file restore without changing the installed state', async () => {
|
||||
// Given an empty installation
|
||||
const wallet = useWalletStore();
|
||||
|
||||
// When malformed backup input crosses the serialized restore boundary
|
||||
const restoring = wallet.restoreFromBackup({ type: 'not-a-wallet' });
|
||||
|
||||
// Then it fails explicitly and does not install a partial wallet
|
||||
await expect(restoring).rejects.toThrow(/valid sattle backup/i);
|
||||
expect(wallet.state).toBe('none');
|
||||
expect(savedKeyExists()).toBe(false);
|
||||
});
|
||||
|
||||
it('tears down the installed owner before a foreign seed restore', async () => {
|
||||
// Given an installed wallet with owner-scoped credentials and trust
|
||||
const wallet = useWalletStore();
|
||||
useNwcStore();
|
||||
await wallet.create(PASSWORD);
|
||||
const oldOwner = wallet.pubkey;
|
||||
if (oldOwner === null) throw new Error('Expected an unlocked owner.');
|
||||
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,
|
||||
ownerId: oldOwner,
|
||||
},
|
||||
]),
|
||||
);
|
||||
await addTrustedMint('old.example', MINT_KEY, { ownerId: oldOwner });
|
||||
localStorage.setItem(
|
||||
'sattle_trusted_mints',
|
||||
JSON.stringify({ version: 1, ownerId: OTHER_OWNER_ID, mints: [] }),
|
||||
);
|
||||
|
||||
// When a different seed replaces that installation
|
||||
await wallet.restoreFromSeed(generateSeedPhrase());
|
||||
|
||||
// Then old credentials are gone and only the successor is active
|
||||
expect(wallet.pubkey).not.toBe(oldOwner);
|
||||
expect(localStorage.getItem('sattle_passkey_slots')).toBeNull();
|
||||
expect(readTrustedMints(oldOwner)).toEqual([]);
|
||||
expect(readTrustedMints(wallet.pubkey ?? undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
decryptSavedLinkingKey,
|
||||
generateSeedPhrase,
|
||||
getPlainLinkingKey,
|
||||
savedKeyIsEncrypted,
|
||||
savedKeyOwnerId,
|
||||
} from '@/lnurlcash/keys';
|
||||
import { unlockWithPasskey } from '@/lnurlcash/passkeys';
|
||||
import { unlockWithBiometrics } from '@/capabilities/biometricUnlock';
|
||||
|
||||
type RunTransition = <T>(transition: () => Promise<T>) => Promise<T>;
|
||||
type InstallSeed = (seedPhrase: string, password?: string) => Promise<void>;
|
||||
type Activate = (linkingKey: Uint8Array, ownerWasMissing: boolean) => Promise<void>;
|
||||
|
||||
type WalletAccessOptions = Readonly<{
|
||||
runTransition: RunTransition;
|
||||
installSeed: InstallSeed;
|
||||
activate: Activate;
|
||||
canInit: () => boolean;
|
||||
}>;
|
||||
|
||||
export const createWalletAccess = ({
|
||||
runTransition,
|
||||
installSeed,
|
||||
activate,
|
||||
canInit,
|
||||
}: WalletAccessOptions) => {
|
||||
const activateSavedKey = async (linkingKey: Uint8Array | null): Promise<void> => {
|
||||
if (!linkingKey) throw new Error('No wallet on this device.');
|
||||
await activate(linkingKey, savedKeyOwnerId() === null);
|
||||
};
|
||||
const create = (password?: string): Promise<string> =>
|
||||
runTransition(async () => {
|
||||
const phrase = generateSeedPhrase();
|
||||
await installSeed(phrase, password);
|
||||
return phrase;
|
||||
});
|
||||
const restoreFromSeed = (seedPhrase: string, password?: string): Promise<void> =>
|
||||
runTransition(() => installSeed(seedPhrase, password));
|
||||
const unlock = (password?: string): Promise<void> =>
|
||||
runTransition(async () => {
|
||||
const linkingKey = savedKeyIsEncrypted()
|
||||
? await decryptSavedLinkingKey(password || '')
|
||||
: getPlainLinkingKey();
|
||||
await activateSavedKey(linkingKey);
|
||||
});
|
||||
const unlockWithPasskeyCredential = (): Promise<void> =>
|
||||
runTransition(async () => {
|
||||
await activate(await unlockWithPasskey(), false);
|
||||
});
|
||||
const unlockWithBiometric = (): Promise<void> =>
|
||||
runTransition(async () => {
|
||||
await activateSavedKey(await unlockWithBiometrics());
|
||||
});
|
||||
const init = (): Promise<void> =>
|
||||
runTransition(async () => {
|
||||
if (!canInit() || savedKeyIsEncrypted()) return;
|
||||
await activateSavedKey(getPlainLinkingKey());
|
||||
});
|
||||
return {
|
||||
create,
|
||||
init,
|
||||
restoreFromSeed,
|
||||
unlock,
|
||||
unlockWithBiometric,
|
||||
unlockWithPasskey: unlockWithPasskeyCredential,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { serverOf } from 'lnurlcash-kit';
|
||||
|
||||
import { grandfatherTrustedMint } from '@/lnurlcash/trustedMints';
|
||||
import type { Bearer } from '@/lnurlcash/types';
|
||||
|
||||
export const restoreHeldMintTrust = async (
|
||||
bearers: readonly Bearer[],
|
||||
ownerId: string,
|
||||
onError: (message: string) => void,
|
||||
): Promise<void> => {
|
||||
for (const bearer of bearers) {
|
||||
if (!bearer.mintPubkey) continue;
|
||||
try {
|
||||
await grandfatherTrustedMint(serverOf(bearer.url), bearer.mintPubkey, ownerId);
|
||||
} catch (error) {
|
||||
onError(
|
||||
error instanceof Error
|
||||
? `Funds loaded, but mint trust could not be restored: ${error.message}`
|
||||
: 'Funds loaded, but mint trust could not be restored.',
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user