mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: backup and security settings pages, nostr restore onboarding, passkey unlock entry
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
import type { BackupPartPayload, BackupPublisher } from '@/lnurlcash/nostrBackup';
|
||||
import {
|
||||
backupPubkey,
|
||||
createBackupPublisher,
|
||||
deriveBackupKey,
|
||||
publishBackup,
|
||||
restoreFromNostr,
|
||||
} from '@/lnurlcash/nostrBackup';
|
||||
import type { NostrRestoreResult } from '@/lnurlcash/nostrBackup';
|
||||
import { loadSettings, persistSettings, readEncryptedBearers } from '@/lnurlcash/storage';
|
||||
import { readTrustedMints } from '@/lnurlcash/trustedMints';
|
||||
import { useWalletStore } from './wallet';
|
||||
import { useMintsStore } from './mints';
|
||||
|
||||
// a small set of well-known, long-lived relays - editable in the UI, and
|
||||
// only persisted once the holder actually changes them (absent = defaults)
|
||||
export const DEFAULT_NOSTR_RELAYS: readonly string[] = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.primal.net',
|
||||
];
|
||||
|
||||
// bursts of edits (a receive plus its mint trust plus a settings change)
|
||||
// collapse into one publish of the final state per quiet window
|
||||
const PUBLISH_DEBOUNCE_MS = 5000;
|
||||
|
||||
export const normalizeRelay = (input: string): string => {
|
||||
const relay = input.trim().toLowerCase().replace(/\/+$/, '');
|
||||
if (!/^wss?:\/\/\S+$/.test(relay)) {
|
||||
throw new Error('A relay is a WebSocket URL, like wss://relay.example.com.');
|
||||
}
|
||||
return relay;
|
||||
};
|
||||
|
||||
// The nostr-backup control surface: the enabled/relays settings (persisted
|
||||
// in wallet settings), the debounced publisher wired to store changes while
|
||||
// the wallet is unlocked, and the manual back-up/restore actions. The
|
||||
// engine (lnurlcash/nostrBackup.ts) stays framework-free; everything
|
||||
// reactive lives here.
|
||||
export const useNostrBackupStore = defineStore('nostrBackup', () => {
|
||||
const wallet = useWalletStore();
|
||||
const mints = useMintsStore();
|
||||
|
||||
const settings = loadSettings();
|
||||
const enabled = ref(settings.nostrBackupEnabled ?? false);
|
||||
const relays = ref<string[]>(
|
||||
settings.nostrBackupRelays && settings.nostrBackupRelays.length > 0
|
||||
? [...settings.nostrBackupRelays]
|
||||
: [...DEFAULT_NOSTR_RELAYS],
|
||||
);
|
||||
|
||||
// feedback for the backup page: when the last publish went out, or why
|
||||
// the last attempt failed (a debounced publish has no caller to throw to)
|
||||
const lastPublishAt = ref<number | null>(null);
|
||||
const lastError = ref('');
|
||||
|
||||
// the backup identity is derived from the linking key, so it only exists
|
||||
// while unlocked - locked renders must never touch requireLinkingKey
|
||||
const pubkey = computed(() =>
|
||||
wallet.state === 'unlocked' ? backupPubkey(deriveBackupKey(wallet.requireLinkingKey())) : null,
|
||||
);
|
||||
|
||||
const currentPayload = (): BackupPartPayload => ({
|
||||
notes: readEncryptedBearers(),
|
||||
mints: readTrustedMints(),
|
||||
settings: loadSettings(),
|
||||
});
|
||||
|
||||
const publishNow = async (): Promise<void> => {
|
||||
const secretKey = deriveBackupKey(wallet.requireLinkingKey());
|
||||
await publishBackup(secretKey, currentPayload(), relays.value);
|
||||
lastPublishAt.value = Date.now();
|
||||
};
|
||||
|
||||
// ---- debounced publishing lifecycle ----
|
||||
// wired while (unlocked AND enabled) only; both the watchers and the
|
||||
// publisher are torn down the moment either flips, so a locked wallet
|
||||
// never schedules anything and holds no key-material closure
|
||||
let publisher: BackupPublisher | null = null;
|
||||
let stopWatchers: (() => void) | null = null;
|
||||
|
||||
const start = (): void => {
|
||||
if (publisher) return;
|
||||
publisher = createBackupPublisher({
|
||||
delayMs: PUBLISH_DEBOUNCE_MS,
|
||||
publish: async (parts) => {
|
||||
await publishBackup(deriveBackupKey(wallet.requireLinkingKey()), parts, relays.value);
|
||||
lastPublishAt.value = Date.now();
|
||||
lastError.value = '';
|
||||
},
|
||||
onError: (error) => {
|
||||
lastError.value = error instanceof Error ? error.message : 'Backup publish failed.';
|
||||
},
|
||||
});
|
||||
const schedule = () => publisher?.schedule(currentPayload());
|
||||
const stops = [
|
||||
watch(() => wallet.bearers, schedule),
|
||||
watch(() => mints.mints, schedule),
|
||||
watch(() => mints.defaultMint, schedule),
|
||||
];
|
||||
stopWatchers = () => stops.forEach((stop) => stop());
|
||||
// an initial publish on (re)activation, so enabling backup on a wallet
|
||||
// that then sits idle still lands a backup
|
||||
schedule();
|
||||
};
|
||||
|
||||
const stop = (): void => {
|
||||
stopWatchers?.();
|
||||
stopWatchers = null;
|
||||
publisher?.cancel();
|
||||
publisher = null;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [wallet.state, enabled.value] as const,
|
||||
([state, on]) => {
|
||||
if (state === 'unlocked' && on) start();
|
||||
else stop();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// ---- settings ----
|
||||
const setEnabled = (value: boolean): void => {
|
||||
enabled.value = value;
|
||||
persistSettings({ ...loadSettings(), nostrBackupEnabled: value });
|
||||
};
|
||||
|
||||
const setRelays = (list: string[]): void => {
|
||||
relays.value = list;
|
||||
persistSettings({ ...loadSettings(), nostrBackupRelays: list });
|
||||
};
|
||||
|
||||
// ---- manual actions (the backup page buttons) ----
|
||||
const backupNow = async (): Promise<void> => {
|
||||
lastError.value = '';
|
||||
try {
|
||||
await publishNow();
|
||||
} catch (error) {
|
||||
lastError.value = error instanceof Error ? error.message : 'Backup publish failed.';
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// pulls the newest backup for THIS wallet's key and merges it through the
|
||||
// same applyBackup path as a file restore, then reloads the live list
|
||||
const restore = async (): Promise<NostrRestoreResult> => {
|
||||
const result = await restoreFromNostr(wallet.requireLinkingKey(), relays.value);
|
||||
await wallet.reloadBearers();
|
||||
return result;
|
||||
};
|
||||
|
||||
return {
|
||||
enabled,
|
||||
relays,
|
||||
pubkey,
|
||||
lastPublishAt,
|
||||
lastError,
|
||||
setEnabled,
|
||||
setRelays,
|
||||
backupNow,
|
||||
restore,
|
||||
};
|
||||
});
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
clearTrustedMints
|
||||
} from '@/lnurlcash/trustedMints'
|
||||
import {clearSettings} from '@/lnurlcash/storage'
|
||||
import {unlockWithPasskey as unlockWithPasskeyEngine} from '@/lnurlcash/passkeys'
|
||||
import {msatToSats} from '@/lnurlcash/units'
|
||||
import {useActivityStore} from './activity'
|
||||
|
||||
@@ -56,6 +57,10 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
const bearers = ref<Bearer[]>([])
|
||||
const pubkey = ref<string | null>(null)
|
||||
let aesKey: CryptoKey | null = null
|
||||
// the linking key itself, only while unlocked - needed by backup/passkey
|
||||
// operations (nostrBackup derives the backup key from it, passkey
|
||||
// registration wraps it). Never exposed reactively; cleared on lock/forget
|
||||
let currentLinkingKey: Uint8Array | null = null
|
||||
|
||||
// ---- idle auto-lock bookkeeping ----
|
||||
let lastActivity = Date.now()
|
||||
@@ -97,6 +102,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
// just auto-unlock again, so the UI only offers Lock when encrypted
|
||||
if (!savedKeyIsEncrypted()) return
|
||||
aesKey = null
|
||||
currentLinkingKey = null
|
||||
pubkey.value = null
|
||||
bearers.value = []
|
||||
useActivityStore().unload()
|
||||
@@ -145,6 +151,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
const activate = async (linkingKey: Uint8Array) => {
|
||||
const key = await deriveBearerAesKey(linkingKey)
|
||||
aesKey = key
|
||||
currentLinkingKey = linkingKey
|
||||
pubkey.value = linkingPubKeyHex(linkingKey)
|
||||
const loaded = await loadBearers(key)
|
||||
bearers.value = loaded
|
||||
@@ -193,6 +200,12 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
await activate(linkingKey)
|
||||
}
|
||||
|
||||
// passkey unlock (passkeys.ts): the ceremony unwraps the SAME linking key
|
||||
// the password path protects, so activation is identical either way
|
||||
const unlockWithPasskey = async (): Promise<void> => {
|
||||
await activate(await unlockWithPasskeyEngine())
|
||||
}
|
||||
|
||||
// app-start entry point (boot/wallet.ts): a plaintext-stored key unlocks
|
||||
// without a password; an encrypted one waits on the unlock screen
|
||||
const init = async (): Promise<void> => {
|
||||
@@ -214,6 +227,7 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
clearSettings()
|
||||
useActivityStore().unloadAndClear()
|
||||
aesKey = null
|
||||
currentLinkingKey = null
|
||||
pubkey.value = null
|
||||
bearers.value = []
|
||||
stopIdleWatch()
|
||||
@@ -225,6 +239,14 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
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 => {
|
||||
if (!currentLinkingKey) throw new Error('Wallet is locked.')
|
||||
return currentLinkingKey
|
||||
}
|
||||
|
||||
// the one entry point for new notes (minted, received, carved outputs):
|
||||
// persists first, then updates state. Holding a bearer from a mint
|
||||
// trusts it by default - this is the one path that never asks (see
|
||||
@@ -305,10 +327,12 @@ export const useWalletStore = defineStore('wallet', () => {
|
||||
create,
|
||||
restoreFromSeed,
|
||||
unlock,
|
||||
unlockWithPasskey,
|
||||
lock,
|
||||
init,
|
||||
forgetWallet,
|
||||
postponeLock,
|
||||
requireLinkingKey,
|
||||
addBearers,
|
||||
updateBearer,
|
||||
markSpent,
|
||||
|
||||
Reference in New Issue
Block a user