mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
// allow: SIZE_OK — indivisible wallet lifecycle state machine and its public Pinia surface.
|
|
import { computed, onScopeDispose, ref } from 'vue';
|
|
import { defineStore } from 'pinia';
|
|
import { cashNodeFromHex } from 'lnurlcash-kit';
|
|
|
|
import {
|
|
clearSavedLinkingKey,
|
|
clearSavedWalletMaterial,
|
|
deriveBearerAesKey,
|
|
savedWalletMaterialExists,
|
|
savedWalletMaterialIsEncrypted,
|
|
savedWalletMaterialOwnerId,
|
|
walletMaterialLinkingKey,
|
|
} from '@/lnurlcash/keys';
|
|
import type { WalletMaterialV2 } from '@/lnurlcash/keys';
|
|
import { walletMaterialOwnerId } from '@/lnurlcash/storage/storedSecret';
|
|
import {
|
|
loadBearers,
|
|
clearAllBearers,
|
|
applyBackup,
|
|
parseBackupFile,
|
|
clearSettings,
|
|
} from '@/lnurlcash/storage';
|
|
import type { RestoreResult } from '@/lnurlcash/storage';
|
|
import { disableBiometricUnlock } from '@/capabilities/biometricUnlock';
|
|
import { restoreFromNostr as restoreFromNostrEngine } from '@/lnurlcash/nostrBackup';
|
|
import { useActivityStore } from './activity';
|
|
import { createWalletFunds } from './walletFunds';
|
|
import { createWalletIdleWatch } from './walletIdle';
|
|
import { createWalletAccess } from './walletAccess';
|
|
import { restoreHeldMintTrust } from './walletActivation';
|
|
import { startWalletOwnerMonitor } from './walletOwnerMonitor';
|
|
import { createWalletOwnerFence } from './walletOwnerFence';
|
|
import type { WalletState } from './walletOwnerFence';
|
|
import {
|
|
clearOwnerAuthorizations,
|
|
clearUnownedAuthorizations,
|
|
createSeedInstaller,
|
|
createWalletTransitionQueue,
|
|
migrateProvenLegacyOwner,
|
|
resetUnsupportedLegacyWalletState,
|
|
stopWalletNwcSession,
|
|
WalletLifecycleError,
|
|
} from './walletLifecycle';
|
|
|
|
export { TrustedMintPostCommitError } from './walletFunds';
|
|
|
|
// 'none': no wallet on this device yet -> setup
|
|
// 'locked': wallet material present but password-encrypted -> unlock
|
|
// 'unlocked': the complete material (linking key + cash root) is in memory
|
|
export type { WalletState } from './walletOwnerFence';
|
|
|
|
// a plaintext-stored material record also starts 'locked' - init() unlocks
|
|
// it immediately without a password, keeping a single code path for deriving
|
|
// the AES key and loading bearers
|
|
export const useWalletStore = defineStore('wallet', () => {
|
|
const state = ref<WalletState>(savedWalletMaterialExists() ? 'locked' : 'none');
|
|
const pubkey = ref<string | null>(null);
|
|
const auxiliaryError = ref('');
|
|
const lifecycleError = ref('');
|
|
let aesKey: CryptoKey | null = null;
|
|
// the complete v2 wallet material, only while unlocked - the cash root
|
|
// funds every BIP-32 secret derivation and the linking key inside it backs
|
|
// backup/passkey operations (nostrBackup derives the backup key from it,
|
|
// passkey and biometric enrollment wrap the whole value). Never exposed
|
|
// reactively; cleared on lock/forget/failed activation
|
|
let currentMaterial: WalletMaterialV2 | null = null;
|
|
let lifecycleToken = 0;
|
|
let acceptingOwnerWork = false;
|
|
|
|
const lockWarningSecondsLeft = ref<number | null>(null);
|
|
const runTransition = createWalletTransitionQueue({
|
|
onStart: () => (lifecycleError.value = ''),
|
|
onError: (error) => {
|
|
lifecycleError.value = error instanceof Error ? error.message : 'Wallet transition failed.';
|
|
},
|
|
}).run;
|
|
|
|
const encrypted = computed(() => savedWalletMaterialIsEncrypted());
|
|
|
|
const ownerFence = createWalletOwnerFence({
|
|
state: () => state.value,
|
|
ownerId: () => pubkey.value,
|
|
lifecycleToken: () => lifecycleToken,
|
|
accepting: () => acceptingOwnerWork,
|
|
});
|
|
|
|
const clearRuntime = (): void => {
|
|
aesKey = null;
|
|
currentMaterial = null;
|
|
pubkey.value = null;
|
|
funds.clear();
|
|
useActivityStore().unload();
|
|
};
|
|
|
|
// ends every captured fence and drops the reactive owner identity. Only
|
|
// ever runs after accepted NWC work has drained (or failed to): an
|
|
// operation past its irreversible melt must stay commit-capable until
|
|
// then, and stop() rejects new requests the moment it is called, so no
|
|
// post-lock work is accepted while the fence stays valid
|
|
const invalidateLifecycle = (): void => {
|
|
acceptingOwnerWork = false;
|
|
lifecycleToken += 1;
|
|
state.value = 'locked';
|
|
pubkey.value = null;
|
|
};
|
|
|
|
const deactivateSession = async (): Promise<void> => {
|
|
acceptingOwnerWork = false;
|
|
stopOwnerChanges();
|
|
idleWatch.stop();
|
|
try {
|
|
await stopWalletNwcSession();
|
|
} finally {
|
|
// even a rejected drain ends the session: 'locked' never holds key
|
|
// material and no captured fence stays valid
|
|
invalidateLifecycle();
|
|
clearRuntime();
|
|
}
|
|
};
|
|
|
|
let stopOwnerChanges = (): void => {};
|
|
const observeOwnerChanges = (): void => {
|
|
stopOwnerChanges();
|
|
stopOwnerChanges = startWalletOwnerMonitor({
|
|
snapshot: () => ({ token: lifecycleToken, state: state.value, ownerId: pubkey.value }),
|
|
deactivate: deactivateSession,
|
|
runTransition,
|
|
});
|
|
};
|
|
onScopeDispose(() => stopOwnerChanges());
|
|
|
|
const lock = (): Promise<void> =>
|
|
runTransition(async () => {
|
|
if (!savedWalletMaterialIsEncrypted()) return;
|
|
await deactivateSession();
|
|
});
|
|
|
|
const idleWatch = createWalletIdleWatch({
|
|
isEncrypted: savedWalletMaterialIsEncrypted,
|
|
isUnlocked: () => state.value === 'unlocked',
|
|
isLockWarningVisible: () => lockWarningSecondsLeft.value !== null,
|
|
lock,
|
|
setWarningSecondsLeft: (seconds) => {
|
|
lockWarningSecondsLeft.value = seconds;
|
|
},
|
|
});
|
|
|
|
// Activation enters the unlocked state for exactly the saved material.
|
|
// The owner monitor is subscribed BEFORE the first async gap and the saved
|
|
// owner is re-read immediately before exposing 'unlocked': a replacement
|
|
// that landed during deferred loading must fence this activation instead
|
|
// of letting a stale owner through
|
|
const activate = async (material: WalletMaterialV2, ownerWasMissing: boolean): Promise<void> => {
|
|
auxiliaryError.value = '';
|
|
const ownerId = walletMaterialOwnerId(material);
|
|
observeOwnerChanges();
|
|
try {
|
|
await migrateProvenLegacyOwner(material, ownerWasMissing);
|
|
if (savedWalletMaterialOwnerId() !== ownerId) {
|
|
throw new WalletLifecycleError('activate', new Error('saved wallet changed'));
|
|
}
|
|
const key = await deriveBearerAesKey(walletMaterialLinkingKey(material));
|
|
const loaded = await loadBearers(key);
|
|
const activity = useActivityStore();
|
|
await activity.loadFor(key);
|
|
await restoreHeldMintTrust(loaded, ownerId, (message) => {
|
|
auxiliaryError.value = message;
|
|
});
|
|
if (savedWalletMaterialOwnerId() !== ownerId) {
|
|
throw new WalletLifecycleError('activate', new Error('saved wallet changed'));
|
|
}
|
|
aesKey = key;
|
|
currentMaterial = material;
|
|
lifecycleToken += 1;
|
|
pubkey.value = ownerId;
|
|
funds.replace(loaded);
|
|
acceptingOwnerWork = true;
|
|
state.value = 'unlocked';
|
|
await funds.public.recoverPendingMints(ownerFence.capture());
|
|
idleWatch.start();
|
|
} catch (error) {
|
|
stopOwnerChanges();
|
|
acceptingOwnerWork = false;
|
|
lifecycleToken += 1;
|
|
pubkey.value = null;
|
|
clearRuntime();
|
|
// a failed activation is locked when a wallet record exists (retry via
|
|
// unlock) and uninstalled when none does - never half-installed
|
|
state.value = savedWalletMaterialExists() ? 'locked' : 'none';
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const teardownCurrentOwner = async (resetRegistry = false): Promise<void> => {
|
|
const ownerId = savedWalletMaterialOwnerId() ?? pubkey.value;
|
|
acceptingOwnerWork = false;
|
|
stopOwnerChanges();
|
|
idleWatch.stop();
|
|
try {
|
|
// the drain runs before the fence is invalidated and the runtime is
|
|
// cleared so an in-flight fund-critical changeset can still commit
|
|
// (its applyChangeset needs the live fence and key)
|
|
await stopWalletNwcSession();
|
|
invalidateLifecycle();
|
|
clearRuntime();
|
|
if (ownerId === null) await clearUnownedAuthorizations();
|
|
else await clearOwnerAuthorizations(ownerId, resetRegistry);
|
|
await disableBiometricUnlock();
|
|
clearAllBearers();
|
|
useActivityStore().unloadAndClear();
|
|
clearSettings();
|
|
// invalidate the legacy saved-key entry too: old tabs watching it must
|
|
// fence themselves, and the record is residue under the v2 lifecycle
|
|
clearSavedLinkingKey();
|
|
// the saved material goes last: a failure above leaves the complete
|
|
// enrollment (and this record) in place for a safe retry
|
|
clearSavedWalletMaterial();
|
|
state.value = 'none';
|
|
} finally {
|
|
// a failed teardown still ends the session: 'locked' must never hold
|
|
// key material in memory, and no captured fence may stay usable
|
|
if (state.value !== 'none') invalidateLifecycle();
|
|
clearRuntime();
|
|
}
|
|
};
|
|
|
|
const prepareInstallation = async (nextOwnerId: string): Promise<void> => {
|
|
// the install path re-runs the alpha reset, so a boot-time reset that
|
|
// failed (e.g. native biometric deletion) is retried before any
|
|
// successor is installed
|
|
await resetUnsupportedLegacyWalletState();
|
|
const installedOwner = savedWalletMaterialOwnerId() ?? pubkey.value;
|
|
if (savedWalletMaterialExists() && installedOwner === nextOwnerId) {
|
|
if (state.value === 'unlocked') await deactivateSession();
|
|
return;
|
|
}
|
|
if (savedWalletMaterialExists() || state.value === 'unlocked') {
|
|
await teardownCurrentOwner(true);
|
|
}
|
|
await clearUnownedAuthorizations();
|
|
};
|
|
|
|
const installSeed = createSeedInstaller({
|
|
prepareInstallation,
|
|
activate: (material) => activate(material, false),
|
|
});
|
|
|
|
const access = createWalletAccess({
|
|
runTransition,
|
|
installSeed,
|
|
activate,
|
|
canInit: () => state.value === 'locked',
|
|
onResetError: (error) => {
|
|
lifecycleError.value = error instanceof Error ? error.message : 'Wallet reset failed.';
|
|
},
|
|
});
|
|
|
|
const restoreFromBackup = (data: unknown): Promise<RestoreResult> =>
|
|
runTransition(async () => {
|
|
const backup = parseBackupFile(data);
|
|
const hadSavedMaterial = savedWalletMaterialExists();
|
|
const activeOwner = pubkey.value;
|
|
const activeMaterial = state.value === 'unlocked' ? requireWalletMaterial() : null;
|
|
if (activeMaterial !== null) await deactivateSession();
|
|
if (!hadSavedMaterial) await clearUnownedAuthorizations();
|
|
const result = await applyBackup(backup, activeOwner ?? undefined);
|
|
// a file-carried legacy linking key can never become v2 material (the
|
|
// cash root is unrecoverable from it): the record applyBackup just
|
|
// installed is residue here, and removing it keeps old tabs fenced
|
|
clearSavedLinkingKey();
|
|
if (activeMaterial !== null) await activate(activeMaterial, false);
|
|
return result;
|
|
});
|
|
|
|
const restoreFromNostr = (
|
|
seedPhrase: string,
|
|
relays: string[],
|
|
password?: string,
|
|
): Promise<void> =>
|
|
runTransition(() =>
|
|
installSeed(seedPhrase, password, async (material) => {
|
|
await restoreFromNostrEngine(walletMaterialLinkingKey(material), relays);
|
|
}),
|
|
);
|
|
|
|
const restoreCurrentFromNostr = (relays: string[]) =>
|
|
runTransition(async () => {
|
|
const material = requireWalletMaterial();
|
|
await deactivateSession();
|
|
const result = await restoreFromNostrEngine(walletMaterialLinkingKey(material), relays);
|
|
await activate(material, false);
|
|
return result;
|
|
});
|
|
|
|
// wipes this wallet from the device entirely - the wallet material, every
|
|
// bearer record, the activity log, and the non-secret registries that
|
|
// would otherwise linger as a fingerprint of it. Not recoverable by
|
|
// restoring the same seed afterward (the ciphertexts themselves are
|
|
// gone); only a backup downloaded before this runs can bring the notes
|
|
// back - the UI should prompt for one
|
|
const forgetWallet = (): Promise<void> =>
|
|
runTransition(() =>
|
|
teardownCurrentOwner().catch((error) => {
|
|
throw new WalletLifecycleError('forget', error);
|
|
}),
|
|
);
|
|
|
|
const requireKey = (): CryptoKey => {
|
|
if (!aesKey) throw new Error('Wallet is locked.');
|
|
return aesKey;
|
|
};
|
|
|
|
// narrow accessor for the operations that need the key material itself
|
|
// (nostr backup key derivation, passkey registration) - never reactive,
|
|
// throws when locked, so callers can't accidentally hold a stale key
|
|
const requireLinkingKey = (): Uint8Array => walletMaterialLinkingKey(requireWalletMaterial());
|
|
|
|
// the complete material for the enrollment flows that wrap it (passkey,
|
|
// biometric) - same access rules as requireLinkingKey
|
|
const requireWalletMaterial = (): WalletMaterialV2 => {
|
|
if (!acceptingOwnerWork || !currentMaterial) throw new Error('Wallet is locked.');
|
|
return currentMaterial;
|
|
};
|
|
|
|
const funds = createWalletFunds({
|
|
requireKey,
|
|
requireCashRoot: () => cashNodeFromHex(requireWalletMaterial().cashRootHex),
|
|
ownerId: () => pubkey.value ?? undefined,
|
|
setAuxiliaryError: (message) => {
|
|
auxiliaryError.value = message;
|
|
},
|
|
});
|
|
|
|
return {
|
|
state,
|
|
pubkey,
|
|
auxiliaryError,
|
|
lifecycleError,
|
|
encrypted,
|
|
lockWarningSecondsLeft,
|
|
...funds.public,
|
|
create: access.create,
|
|
restoreFromSeed: access.restoreFromSeed,
|
|
restoreFromBackup,
|
|
restoreFromNostr,
|
|
restoreCurrentFromNostr,
|
|
unlock: access.unlock,
|
|
unlockWithPasskey: access.unlockWithPasskey,
|
|
unlockWithBiometric: access.unlockWithBiometric,
|
|
lock,
|
|
init: access.init,
|
|
forgetWallet,
|
|
postponeLock: idleWatch.postpone,
|
|
requireLinkingKey,
|
|
requireWalletMaterial,
|
|
captureOwnerFence: ownerFence.capture,
|
|
};
|
|
});
|