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