feat: activate only proven wallet owners

This commit is contained in:
2026-08-22 16:56:24 +02:00
parent 62b93baa05
commit c319e11d3e
5 changed files with 339 additions and 0 deletions
@@ -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([]);
});
});
+68
View File
@@ -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,
};
};
+23
View File
@@ -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.',
);
}
}
};