feat: backup and security settings pages, nostr restore onboarding, passkey unlock entry

This commit is contained in:
2026-08-19 23:55:59 +02:00
parent 7db34083e6
commit 1920a00cd6
11 changed files with 990 additions and 7 deletions
+4
View File
@@ -1,5 +1,6 @@
import { defineBoot } from '#q-app';
import { useWalletStore } from '@/stores/wallet';
import { useNostrBackupStore } from '@/stores/nostrBackup';
// Wallet lifecycle bootstrap: reflects whatever is on this device into the
// wallet store at app start - a plaintext-stored key unlocks straight away,
@@ -8,4 +9,7 @@ import { useWalletStore } from '@/stores/wallet';
export default defineBoot(async () => {
const wallet = useWalletStore();
await wallet.init();
// instantiating the store arms its watchers: while the wallet is unlocked
// and nostr backup is enabled, store changes schedule debounced publishes
useNostrBackupStore();
});
+78
View File
@@ -0,0 +1,78 @@
<template>
<div>
<div v-if="modelValue.length" class="row q-gutter-xs q-mb-sm">
<q-chip
v-for="relay in modelValue"
:key="relay"
removable
dense
color="secondary"
text-color="primary"
:aria-label="`Remove ${relay}`"
@remove="remove(relay)"
>
{{ relay }}
</q-chip>
</div>
<div v-else class="text-caption text-warning q-mb-sm">
No relays configured - a backup has nowhere to go.
</div>
<q-input
v-model="draft"
dark
outlined
dense
color="primary"
label="Add a relay"
placeholder="wss://relay.example.com"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
:error="error !== ''"
:error-message="error"
class="q-mb-sm"
@keyup.enter="add"
/>
<q-btn
flat
dense
no-caps
color="primary"
label="Add relay"
:disable="draft.trim() === ''"
@click="add"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { normalizeRelay } from '@/stores/nostrBackup';
const props = defineProps<{ modelValue: string[] }>();
const emit = defineEmits<{ 'update:modelValue': [relays: string[]] }>();
const draft = ref('');
const error = ref('');
const add = () => {
error.value = '';
try {
const relay = normalizeRelay(draft.value);
if (!props.modelValue.includes(relay)) {
emit('update:modelValue', [...props.modelValue, relay]);
}
draft.value = '';
} catch (err) {
error.value = err instanceof Error ? err.message : 'That is not a relay address.';
}
};
const remove = (relay: string) => {
emit(
'update:modelValue',
props.modelValue.filter((r) => r !== relay),
);
};
</script>
+31
View File
@@ -39,6 +39,17 @@
@click="unlock"
/>
<q-btn
v-if="passkeyAvailable"
outline
color="primary"
icon="fingerprint"
label="Unlock with passkey"
class="full-width q-mt-sm"
:loading="passkeyBusy"
@click="unlockViaPasskey"
/>
<div class="text-center q-mt-md">
<q-btn
flat
@@ -57,6 +68,7 @@
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { hasPasskeySlots } from '@/lnurlcash/passkeys';
import { useWalletStore } from '@/stores/wallet';
const emit = defineEmits<{ unlocked: [] }>();
@@ -69,6 +81,25 @@ const showPassword = ref(false);
const busy = ref(false);
const error = ref('');
// slots live in plain localStorage - a sync read at setup is enough; they
// can only change from the security page while unlocked
const passkeyAvailable = hasPasskeySlots();
const passkeyBusy = ref(false);
const unlockViaPasskey = async () => {
if (passkeyBusy.value) return;
passkeyBusy.value = true;
error.value = '';
try {
await wallet.unlockWithPasskey();
emit('unlocked');
} catch (err) {
error.value = err instanceof Error ? err.message : 'Passkey unlock failed.';
} finally {
passkeyBusy.value = false;
}
};
const unlock = async () => {
if (busy.value || password.value === '') return;
busy.value = true;
+12 -1
View File
@@ -4,6 +4,10 @@
export type WalletSettings = {
defaultMint?: string
// nostr backup (see nostrBackup.ts): off unless the holder turns it on;
// relays are only persisted once edited - absent means the UI's defaults
nostrBackupEnabled?: boolean
nostrBackupRelays?: string[]
}
const SETTINGS_STORAGE_KEY = 'sattle_settings'
@@ -17,7 +21,14 @@ export const loadSettings = (): WalletSettings => {
const s = parsed as Record<string, unknown>
return {
defaultMint:
typeof s.defaultMint === 'string' ? s.defaultMint : undefined
typeof s.defaultMint === 'string' ? s.defaultMint : undefined,
nostrBackupEnabled:
typeof s.nostrBackupEnabled === 'boolean'
? s.nostrBackupEnabled
: undefined,
nostrBackupRelays: Array.isArray(s.nostrBackupRelays)
? s.nostrBackupRelays.filter((r): r is string => typeof r === 'string')
: undefined
}
} catch {
return {}
+243
View File
@@ -0,0 +1,243 @@
<template>
<q-page class="q-pa-md">
<div class="row items-center q-mb-md">
<q-btn
flat
dense
round
color="primary"
icon="arrow_back"
aria-label="Back"
@click="router.push('/settings')"
/>
<div class="text-h5 text-weight-bold text-primary q-ml-sm">Backup</div>
</div>
<q-card v-if="wallet.state !== 'unlocked'" class="sattle-card q-pa-lg">
<div class="text-body1 text-grey-4">
Unlock your wallet first - backup operations need the wallet's key in memory.
</div>
</q-card>
<template v-else>
<!-- recovery phrase: sattle never stores it, so there is nothing to
reveal - the honest answer plus the paths that DO work -->
<q-list class="sattle-card q-mb-md" bordered>
<q-item-label header class="text-primary text-weight-bold"> Recovery phrase </q-item-label>
<div class="q-pa-md q-pt-sm text-body2 text-grey-4">
Your recovery phrase was shown exactly once when this wallet was created and is never
stored anywhere - not encrypted, not on this device. It cannot be shown again. The backup
file and nostr backup below are the recovery paths you can still set up.
</div>
</q-list>
<!-- backup file -->
<q-list class="sattle-card q-mb-md" bordered>
<q-item-label header class="text-primary text-weight-bold"> Backup file </q-item-label>
<div class="q-pa-md q-pt-sm">
<div class="text-body2 text-grey-4 q-mb-sm">
Downloads a JSON file with this wallet's notes, mint list and settings. Notes are
encrypted - nobody can read them from the file without your recovery phrase. The mint
list and settings are readable by anyone who opens the file.
</div>
<div v-if="wallet.encrypted" class="text-body2 text-grey-4 q-mb-md">
This wallet is password-protected, so the file also carries your wallet key, encrypted
with your password - the file alone restores a device completely.
</div>
<q-banner v-else dense rounded class="sattle-card text-warning q-mb-md">
<template #avatar>
<q-icon name="warning" color="warning" />
</template>
This wallet has no password, so the file does NOT include your wallet key - restoring
takes this file plus your recovery phrase.
</q-banner>
<q-btn
unelevated
color="primary"
text-color="dark"
icon="download"
label="Download backup file"
class="full-width"
@click="downloadBackupFile"
/>
</div>
</q-list>
<!-- nostr backup -->
<q-list class="sattle-card q-mb-md" bordered>
<q-item-label header class="text-primary text-weight-bold"> Nostr backup </q-item-label>
<div class="q-pa-md q-pt-sm">
<div class="row items-center justify-between q-mb-sm">
<div class="text-body2 text-grey-4 col q-pr-md">
Keeps an encrypted copy of your notes, mints and settings on public nostr relays. Only
your recovery phrase can decrypt them.
</div>
<q-toggle
:model-value="nostr.enabled"
color="primary"
aria-label="Enable nostr backup"
@update:model-value="nostr.setEnabled"
/>
</div>
<template v-if="nostr.enabled">
<q-banner dense rounded class="sattle-card text-warning q-mb-md">
<template #avatar>
<q-icon name="devices" color="warning" />
</template>
Built for one device at a time: the newest backup replaces the older one. If you run
two devices with the same phrase, the last one to back up wins.
</q-banner>
<div class="text-caption text-grey-5 q-mb-xs">Backup address</div>
<div class="row items-center q-mb-md no-wrap">
<code class="backup-pubkey text-grey-4 ellipsis">{{ nostr.pubkey }}</code>
<q-btn
flat
dense
round
size="sm"
color="primary"
icon="content_copy"
aria-label="Copy backup address"
@click="copyPubkey"
/>
</div>
<div class="text-caption text-grey-5 q-mb-xs">Relays</div>
<RelaysEditor v-model="relayModel" />
<div class="row q-gutter-sm q-mt-md">
<q-btn
unelevated
color="primary"
text-color="dark"
label="Back up now"
class="col"
:loading="backupBusy"
:disable="nostr.relays.length === 0"
@click="backUpNow"
/>
<q-btn
outline
color="primary"
label="Restore from nostr"
class="col"
:loading="restoreBusy"
:disable="nostr.relays.length === 0"
@click="restoreFromNostrAction"
/>
</div>
<div v-if="nostr.lastPublishAt" class="text-caption text-grey-5 q-mt-sm">
Last backup: {{ new Date(nostr.lastPublishAt).toLocaleString() }}
</div>
<div v-if="nostr.lastError" class="text-caption text-negative q-mt-sm">
{{ nostr.lastError }}
</div>
<div v-if="restoreSummary" class="text-caption text-positive q-mt-sm">
{{ restoreSummary }}
</div>
</template>
</div>
</q-list>
</template>
</q-page>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { copyToClipboard, useQuasar } from 'quasar';
import { buildBackup } from '@/lnurlcash/storage';
import { useWalletStore } from '@/stores/wallet';
import { useNostrBackupStore } from '@/stores/nostrBackup';
import RelaysEditor from '@/components/RelaysEditor.vue';
const router = useRouter();
const $q = useQuasar();
const wallet = useWalletStore();
const nostr = useNostrBackupStore();
const toast = (type: 'positive' | 'negative', message: string): void => {
if (typeof $q.notify === 'function') {
$q.notify({ type, message, position: 'top', timeout: 3000 });
}
};
const errorMessage = (err: unknown): string =>
err instanceof Error ? err.message : 'Something went wrong.';
// ---- backup file ----
const downloadBackupFile = () => {
const backup = buildBackup();
const blob = new Blob([JSON.stringify(backup, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `sattle-backup-${new Date().toISOString().slice(0, 10)}.json`;
anchor.click();
URL.revokeObjectURL(url);
};
// ---- nostr backup ----
const relayModel = computed({
get: () => nostr.relays,
set: (relays: string[]) => nostr.setRelays(relays),
});
const copyPubkey = () => {
if (!nostr.pubkey) return;
void copyToClipboard(nostr.pubkey).then(() => toast('positive', 'Backup address copied.'));
};
const backupBusy = ref(false);
const restoreBusy = ref(false);
const restoreSummary = ref('');
const backUpNow = async () => {
backupBusy.value = true;
restoreSummary.value = '';
try {
await nostr.backupNow();
toast('positive', 'Backup published to your relays.');
} catch (err) {
toast('negative', errorMessage(err));
} finally {
backupBusy.value = false;
}
};
const restoreFromNostrAction = async () => {
restoreBusy.value = true;
restoreSummary.value = '';
try {
const result = await nostr.restore();
if (result.found.length === 0) {
restoreSummary.value = 'No backup found for this wallet on your relays.';
return;
}
const parts = [
`${result.added} note(s) added, ${result.skipped} already present`,
`${result.trustedMintsAdded} mint(s) added`,
];
if (result.settingsRestored) parts.push('settings restored');
restoreSummary.value = `Restored: ${parts.join(', ')}.`;
toast('positive', 'Backup restored.');
} catch (err) {
toast('negative', errorMessage(err));
} finally {
restoreBusy.value = false;
}
};
</script>
<style lang="scss" scoped>
.backup-pubkey {
font-size: 0.75rem;
word-break: break-all;
}
</style>
+233
View File
@@ -0,0 +1,233 @@
<template>
<q-page class="q-pa-md">
<div class="row items-center q-mb-md">
<q-btn
flat
dense
round
color="primary"
icon="arrow_back"
aria-label="Back"
@click="router.push('/settings')"
/>
<div class="text-h5 text-weight-bold text-primary q-ml-sm">Security</div>
</div>
<!-- passkeys -->
<q-list class="sattle-card q-mb-md" bordered separator>
<q-item-label header class="text-primary text-weight-bold">Passkeys</q-item-label>
<q-item v-if="supported === null">
<q-item-section class="text-grey-5">Checking passkey support</q-item-section>
</q-item>
<q-item v-else-if="supported === false">
<q-item-section>
<q-item-label class="text-grey-3">Passkeys aren't available here</q-item-label>
<q-item-label caption class="text-grey-5" style="white-space: normal">
This browser has no passkey authenticator (like Touch ID, Windows Hello or Android
biometrics) with the encryption support sattle needs. Your password unlock keeps working
- nothing to do.
</q-item-label>
</q-item-section>
</q-item>
<template v-else>
<q-item v-if="wallet.state !== 'unlocked'">
<q-item-section class="text-grey-5">
Unlock your wallet first - adding a passkey needs the wallet's key in memory.
</q-item-section>
</q-item>
<template v-else>
<q-item v-if="!slots.length">
<q-item-section class="text-grey-5">
No passkeys yet. A passkey lets you unlock this wallet with your device's screen lock
instead of typing the password.
</q-item-section>
</q-item>
<q-item v-for="slot in slots" :key="slot.credentialId">
<q-item-section>
<q-item-label class="text-grey-3">{{ slot.name || 'Passkey' }}</q-item-label>
<q-item-label caption class="text-grey-5">
Added {{ new Date(slot.createdAt).toLocaleDateString() }}
</q-item-label>
</q-item-section>
<q-item-section side>
<q-btn flat dense no-caps color="negative" label="Remove" @click="askRemove(slot)" />
</q-item-section>
</q-item>
<div class="q-pa-md">
<q-btn
unelevated
color="primary"
text-color="dark"
icon="fingerprint"
label="Add a passkey"
class="full-width"
:loading="registerBusy"
@click="askRegister"
/>
</div>
</template>
</template>
</q-list>
<!-- auto-lock -->
<q-list class="sattle-card q-mb-md" bordered>
<q-item-label header class="text-primary text-weight-bold">Auto-lock</q-item-label>
<q-item>
<q-item-section>
<q-item-label class="text-grey-3"> Locks after 5 minutes without activity </q-item-label>
<q-item-label caption class="text-grey-5" style="white-space: normal">
Applies when your wallet is password-protected. You get a 30-second warning with a "stay
unlocked" option before it locks. The duration isn't configurable yet.
</q-item-label>
</q-item-section>
</q-item>
</q-list>
<q-banner v-if="banner" dense class="bg-negative text-white rounded-borders q-mb-md">
{{ banner }}
</q-banner>
<!-- add-passkey dialog: name it, then the authenticator ceremony runs -->
<q-dialog v-model="registering">
<q-card class="sattle-card q-pa-lg">
<div class="text-h6 text-primary q-mb-sm">Add a passkey</div>
<div class="text-body2 text-grey-4 q-mb-md">
Your device will ask for its screen lock. The passkey only ever unlocks this wallet on
this device - it never leaves it.
</div>
<q-input
v-model="registerName"
dark
outlined
color="primary"
label="Name (optional)"
placeholder="e.g. laptop"
autocomplete="off"
class="q-mb-md"
/>
<div class="row q-gutter-sm justify-end">
<q-btn v-close-popup flat no-caps color="grey-5" label="Cancel" />
<q-btn
unelevated
no-caps
color="primary"
text-color="dark"
label="Create passkey"
:loading="registerBusy"
@click="doRegister"
/>
</div>
</q-card>
</q-dialog>
<!-- remove confirmation -->
<q-dialog v-model="confirmingRemove">
<q-card class="sattle-card q-pa-lg">
<div class="text-h6 text-primary q-mb-sm">Remove passkey</div>
<div class="text-body2 text-grey-4 q-mb-md">
Remove <strong>{{ removeTarget?.name || 'this passkey' }}</strong
>? It will no longer unlock this wallet. You can remove it from your device's passkey list
separately - the wallet can't do that for you.
</div>
<div class="row q-gutter-sm justify-end">
<q-btn v-close-popup flat no-caps color="grey-5" label="Cancel" />
<q-btn
unelevated
no-caps
color="negative"
text-color="white"
label="Remove"
@click="doRemove"
/>
</div>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import type { PasskeySlot } from '@/lnurlcash/passkeys';
import {
passkeySupported,
readPasskeySlots,
registerPasskey,
removePasskey,
} from '@/lnurlcash/passkeys';
import { useWalletStore } from '@/stores/wallet';
const router = useRouter();
const $q = useQuasar();
const wallet = useWalletStore();
const toast = (type: 'positive' | 'negative', message: string): void => {
if (typeof $q.notify === 'function') {
$q.notify({ type, message, position: 'top', timeout: 3000 });
}
};
const banner = ref('');
// null while the async probe runs - the support answer decides which of
// the three states (checking / unsupported / manage) renders
const supported = ref<boolean | null>(null);
const slots = ref<PasskeySlot[]>([]);
onMounted(async () => {
supported.value = await passkeySupported();
slots.value = readPasskeySlots();
});
// ---- register ----
const registering = ref(false);
const registerName = ref('');
const registerBusy = ref(false);
const askRegister = () => {
banner.value = '';
registerName.value = '';
registering.value = true;
};
const doRegister = async () => {
registerBusy.value = true;
banner.value = '';
try {
const name = registerName.value.trim();
await registerPasskey(wallet.requireLinkingKey(), name ? { name } : {});
slots.value = readPasskeySlots();
registering.value = false;
toast('positive', 'Passkey added.');
} catch (err) {
banner.value = err instanceof Error ? err.message : 'Could not add that passkey.';
registering.value = false;
} finally {
registerBusy.value = false;
}
};
// ---- remove ----
const confirmingRemove = ref(false);
const removeTarget = ref<PasskeySlot | null>(null);
const askRemove = (slot: PasskeySlot) => {
banner.value = '';
removeTarget.value = slot;
confirmingRemove.value = true;
};
const doRemove = async () => {
confirmingRemove.value = false;
if (!removeTarget.value) return;
await removePasskey(removeTarget.value.credentialId);
slots.value = readPasskeySlots();
toast('positive', 'Passkey removed.');
};
</script>
+7 -1
View File
@@ -50,7 +50,13 @@ const router = useRouter();
type SettingsItem = { label: string; to?: string };
const groups: { label: string; items: SettingsItem[] }[] = [
{ label: 'Wallet', items: [{ label: 'Backup' }, { label: 'Security' }] },
{
label: 'Wallet',
items: [
{ label: 'Backup', to: '/settings/backup' },
{ label: 'Security', to: '/settings/security' },
],
},
{ label: 'Connections', items: [{ label: 'Nostr Wallet Connect' }, { label: 'Nostr' }] },
{
label: 'Mints',
+183 -5
View File
@@ -32,6 +32,7 @@
{ label: 'Create new', value: 'create' },
{ label: 'Restore seed', value: 'restore' },
{ label: 'Backup file', value: 'backup' },
{ label: 'Nostr backup', value: 'nostr' },
]"
/>
@@ -199,6 +200,107 @@
/>
</div>
<!-- restore from nostr backup -->
<div v-else-if="tab === 'nostr'">
<div class="text-body1 q-mb-md">
If this wallet used nostr backup before, your notes, mints and settings are
waiting on your relays - encrypted so only your recovery phrase can read
them.
</div>
<q-input
v-model="nostrPhrase"
type="textarea"
rows="3"
dark
outlined
color="primary"
label="Your 12-word recovery phrase"
placeholder="twelve words separated by spaces"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
data-1p-ignore
data-lpignore="true"
class="q-mb-md"
/>
<div class="text-caption text-grey-5 q-mb-xs">Relays to look on</div>
<RelaysEditor v-model="nostrRelays" />
<div v-if="nostrError" class="text-negative q-my-sm">{{ nostrError }}</div>
<template v-if="!nostrFound">
<div v-if="nostrLooked" class="text-warning q-my-sm">
No backup found for this phrase on those relays. Check the phrase and the
relay list - or restore from a backup file instead.
</div>
<q-btn
unelevated
color="primary"
text-color="dark"
label="Look for a backup"
class="full-width q-mt-md"
:loading="nostrBusy"
:disable="!nostrPhraseValid || nostrRelays.length === 0"
@click="lookForNostrBackup"
/>
</template>
<template v-else>
<div class="text-body2 text-grey-4 q-my-md">
Found a backup: {{ nostrFound.notes }} note(s), {{ nostrFound.mints }}
mint(s)<template v-if="nostrFound.settings">, settings</template>.
</div>
<div class="text-caption text-grey-5 q-mb-sm">
Password (optional) encrypts your wallet on this device and enables
locking. Minimum {{ MIN_PASSWORD_LENGTH }} characters. Leave empty to
store unencrypted.
</div>
<q-input
v-model="nostrPassword"
type="password"
dark
outlined
color="primary"
label="Password"
autocomplete="new-password"
class="q-mb-sm"
/>
<q-input
v-if="nostrPassword !== ''"
v-model="nostrPasswordConfirm"
type="password"
dark
outlined
color="primary"
label="Confirm password"
autocomplete="new-password"
class="q-mb-sm"
/>
<div
v-if="nostrPassword !== '' && nostrPassword.length < MIN_PASSWORD_LENGTH"
class="text-warning text-caption q-mb-sm"
>
At least {{ MIN_PASSWORD_LENGTH }} characters.
</div>
<div
v-if="nostrPasswordConfirm !== '' && nostrPassword !== nostrPasswordConfirm"
class="text-warning text-caption q-mb-sm"
>
Passwords do not match.
</div>
<q-btn
unelevated
color="primary"
text-color="dark"
label="Restore this backup"
class="full-width q-mt-sm"
:loading="nostrBusy"
:disable="!passwordValid(nostrPassword, nostrPasswordConfirm)"
@click="restoreNostrBackup"
/>
</template>
</div>
<!-- restore from backup file -->
<div v-else>
<div class="text-body1 q-mb-md">
@@ -269,13 +371,22 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ref, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Notify } from 'quasar';
import { deriveWalletLinkingKey, isValidSeedPhrase } from '@/lnurlcash/keys';
import {
backupPubkey,
deriveBackupKey,
fetchBackup,
restoreFromNostr,
} from '@/lnurlcash/nostrBackup';
import { useWalletStore } from '@/stores/wallet';
import { DEFAULT_NOSTR_RELAYS } from '@/stores/nostrBackup';
import { applyBackup, MAX_BACKUP_FILE_BYTES } from '@/lnurlcash/storage';
import type { RestoreResult } from '@/lnurlcash/storage';
import RelaysEditor from '@/components/RelaysEditor.vue';
// the key's ciphertext sits in local storage AND travels inside every backup
// file by design, so this password is the only thing between an offline
@@ -283,14 +394,16 @@ import type { RestoreResult } from '@/lnurlcash/storage';
// no password at all
const MIN_PASSWORD_LENGTH = 8;
type Tab = 'create' | 'restore' | 'backup';
type Tab = 'create' | 'restore' | 'backup' | 'nostr';
const wallet = useWalletStore();
const router = useRouter();
const route = useRoute();
const tab = ref<Tab>(
route.query.tab === 'restore' || route.query.tab === 'backup' ? route.query.tab : 'create',
route.query.tab === 'restore' || route.query.tab === 'backup' || route.query.tab === 'nostr'
? route.query.tab
: 'create',
);
const errorMessage = (err: unknown): string =>
@@ -396,13 +509,78 @@ const restoreFromBackupFile = async (event: Event) => {
}
};
// ---- restore from nostr backup ----
// phrase in -> derive the linking key (and from it the backup key) -> fetch
// the encrypted parts -> preview what was found -> applyBackup via
// restoreFromNostr, then install the key through the ordinary seed path
const nostrPhrase = ref('');
const nostrRelays = ref<string[]>([...DEFAULT_NOSTR_RELAYS]);
const nostrPassword = ref('');
const nostrPasswordConfirm = ref('');
const nostrBusy = ref(false);
const nostrError = ref('');
const nostrLooked = ref(false);
const nostrFound = ref<{ notes: number; mints: number; settings: boolean } | null>(null);
const nostrPhraseValid = computed(() => isValidSeedPhrase(nostrPhrase.value));
const nostrLinkingKey = (): Uint8Array =>
deriveWalletLinkingKey(nostrPhrase.value.trim().toLowerCase());
const lookForNostrBackup = async () => {
nostrBusy.value = true;
nostrError.value = '';
nostrFound.value = null;
nostrLooked.value = false;
try {
const secretKey = deriveBackupKey(nostrLinkingKey());
const parts = await fetchBackup(backupPubkey(secretKey), nostrRelays.value, {
secretKey,
});
nostrLooked.value = true;
if (parts.notes || parts.mints || parts.settings) {
nostrFound.value = {
notes: parts.notes?.length ?? 0,
mints: parts.mints?.length ?? 0,
settings: parts.settings !== undefined,
};
}
} catch (err) {
nostrError.value = errorMessage(err);
} finally {
nostrBusy.value = false;
}
};
const restoreNostrBackup = async () => {
nostrBusy.value = true;
nostrError.value = '';
try {
const linkingKey = nostrLinkingKey();
// merge the fetched parts into local storage FIRST (the same applyBackup
// path as a file restore), then installing the seed activates the wallet
// and loads what was just restored
await restoreFromNostr(linkingKey, nostrRelays.value);
await wallet.restoreFromSeed(
nostrPhrase.value.trim().toLowerCase(),
nostrPassword.value || undefined,
);
Notify.create({ type: 'positive', message: 'Backup restored - welcome back.' });
void router.push('/');
} catch (err) {
nostrError.value = errorMessage(err);
Notify.create({ type: 'negative', message: nostrError.value });
} finally {
nostrBusy.value = false;
}
};
// the backup installed its own key into local storage behind the wallet
// store's back - a full reload re-runs the boot sequence so the app comes up
// against the restored key (unlock screen if it was password-encrypted)
const proceedWithBackupKey = () => {
window.location.assign('/');
};
</script>
};</script>
<style lang="scss" scoped>
.onboarding-panel {
+8
View File
@@ -7,6 +7,14 @@ const routes: RouteRecordRaw[] = [
children: [
{ path: '', component: () => import('@/pages/IndexPage.vue') },
{ path: 'settings', component: () => import('@/pages/SettingsPage.vue') },
{
path: 'settings/backup',
component: () => import('@/pages/BackupPage.vue'),
},
{
path: 'settings/security',
component: () => import('@/pages/SecurityPage.vue'),
},
{
path: 'settings/mints',
component: () => import('@/pages/ManageMintsPage.vue'),
+167
View File
@@ -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,
};
});
+24
View File
@@ -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,