feat: native biometric unlock via biometric-gated wrap of the linking key

This commit is contained in:
2026-08-20 08:31:59 +02:00
parent f525b06966
commit e59b722b5e
3 changed files with 284 additions and 156 deletions
+36 -1
View File
@@ -50,6 +50,17 @@
@click="unlockViaPasskey"
/>
<q-btn
v-if="biometricAvailable"
outline
color="primary"
icon="fingerprint"
label="Unlock with biometrics"
class="full-width q-mt-sm"
:loading="biometricBusy"
@click="unlockViaBiometric"
/>
<div class="text-center q-mt-md">
<q-btn
flat
@@ -65,9 +76,10 @@
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { biometricUnlockAvailable } from '@/capabilities/biometricUnlock';
import { hasPasskeySlots } from '@/lnurlcash/passkeys';
import { useWalletStore } from '@/stores/wallet';
@@ -86,6 +98,29 @@ const error = ref('');
const passkeyAvailable = hasPasskeySlots();
const passkeyBusy = ref(false);
// native biometric unlock (capabilities/biometricUnlock.ts): only probed
// async because hardware availability is a plugin call; always false on web
const biometricAvailable = ref(false);
const biometricBusy = ref(false);
onMounted(async () => {
biometricAvailable.value = await biometricUnlockAvailable();
});
const unlockViaBiometric = async () => {
if (biometricBusy.value) return;
biometricBusy.value = true;
error.value = '';
try {
await wallet.unlockWithBiometric();
emit('unlocked');
} catch (err) {
error.value = err instanceof Error ? err.message : 'Biometric unlock failed.';
} finally {
biometricBusy.value = false;
}
};
const unlockViaPasskey = async () => {
if (passkeyBusy.value) return;
passkeyBusy.value = true;
+88
View File
@@ -73,6 +73,55 @@
</template>
</q-list>
<!-- biometric unlock (native app only - Android WebView has no WebAuthn
platform authenticator for us, so this is the native biometric path) -->
<q-list v-if="biometricNative" class="sattle-card q-mb-md" bordered>
<q-item-label header class="text-primary text-weight-bold"> Biometric unlock </q-item-label>
<q-item v-if="wallet.state !== 'unlocked'">
<q-item-section class="text-grey-5">
Unlock your wallet first - enabling biometric unlock needs the wallet's key in memory.
</q-item-section>
</q-item>
<template v-else>
<q-item>
<q-item-section>
<q-item-label class="text-grey-3">
{{ biometricEnrolled ? 'Biometric unlock is on' : 'Biometric unlock is off' }}
</q-item-label>
<q-item-label caption class="text-grey-5" style="white-space: normal">
Unlock this wallet with your device's screen lock instead of the password. The wallet
key stays wrapped on this device; the biometric prompt gates reading it.
</q-item-label>
</q-item-section>
</q-item>
<div class="q-pa-md">
<q-btn
v-if="!biometricEnrolled"
unelevated
color="primary"
text-color="dark"
icon="fingerprint"
label="Enable biometric unlock"
class="full-width"
:loading="biometricBusy"
@click="doEnableBiometric"
/>
<q-btn
v-else
outline
no-caps
color="negative"
label="Disable biometric unlock"
class="full-width"
:loading="biometricBusy"
@click="doDisableBiometric"
/>
</div>
</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>
@@ -154,6 +203,12 @@ import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import {
disableBiometricUnlock,
enableBiometricUnlock,
isBiometricUnlockEnrolled,
} from '@/capabilities/biometricUnlock';
import { isNative } from '@/capabilities/platform';
import type { PasskeySlot } from '@/lnurlcash/passkeys';
import {
passkeySupported,
@@ -185,6 +240,39 @@ onMounted(async () => {
slots.value = readPasskeySlots();
});
// ---- biometric unlock (native only - see capabilities/biometricUnlock.ts) ----
const biometricNative = isNative();
const biometricEnrolled = ref(biometricNative && isBiometricUnlockEnrolled());
const biometricBusy = ref(false);
const doEnableBiometric = async () => {
biometricBusy.value = true;
banner.value = '';
try {
await enableBiometricUnlock(wallet.requireLinkingKey());
biometricEnrolled.value = true;
toast('positive', 'Biometric unlock enabled.');
} catch (err) {
banner.value = err instanceof Error ? err.message : 'Could not enable biometric unlock.';
} finally {
biometricBusy.value = false;
}
};
const doDisableBiometric = async () => {
biometricBusy.value = true;
banner.value = '';
try {
await disableBiometricUnlock();
biometricEnrolled.value = false;
toast('positive', 'Biometric unlock disabled.');
} catch (err) {
banner.value = err instanceof Error ? err.message : 'Could not disable biometric unlock.';
} finally {
biometricBusy.value = false;
}
};
// ---- register ----
const registering = ref(false);
const registerName = ref('');
+160 -155
View File
@@ -1,6 +1,6 @@
import {computed, ref} from 'vue'
import {defineStore} from 'pinia'
import {serverOf} from 'lnurlcash-kit'
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { serverOf } from 'lnurlcash-kit';
import {
deriveWalletLinkingKey,
@@ -13,109 +13,108 @@ import {
clearSavedLinkingKey,
generateSeedPhrase,
isValidSeedPhrase,
linkingPubKeyHex
} from '@/lnurlcash/keys'
import type {Bearer, NewBearer} from '@/lnurlcash/types'
linkingPubKeyHex,
} from '@/lnurlcash/keys';
import type { Bearer, NewBearer } from '@/lnurlcash/types';
import {
loadBearers,
persistBearer,
deleteBearerRecord,
clearAllBearers,
newBearerId,
mergeBearers
} from '@/lnurlcash/storage'
mergeBearers,
} from '@/lnurlcash/storage';
import {
grandfatherTrustedMint,
lockTrustedMint,
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'
clearTrustedMints,
} from '@/lnurlcash/trustedMints';
import { clearSettings } from '@/lnurlcash/storage';
import { unlockWithPasskey as unlockWithPasskeyEngine } from '@/lnurlcash/passkeys';
import { disableBiometricUnlock, unlockWithBiometrics } from '@/capabilities/biometricUnlock';
import { msatToSats } from '@/lnurlcash/units';
import { useActivityStore } from './activity';
// 'none': no wallet on this device yet -> setup
// 'locked': linking key present but password-encrypted -> unlock
// 'unlocked': linking key (and thus the bearer AES key) in memory
export type WalletState = 'none' | 'locked' | 'unlocked'
export type WalletState = 'none' | 'locked' | 'unlocked';
// idle-timeout auto-lock: only meaningful for a password-encrypted key (see
// lock(), which no-ops otherwise) - 5 minutes with no activity anywhere in
// the tab locks the wallet. lockWarningSecondsLeft goes non-null 30s ahead
// of that so the UI can warn, and postponeLock() is the "stay unlocked"
// hook it offers.
const AUTO_LOCK_MS = 5 * 60 * 1000
const LOCK_WARNING_MS = 30 * 1000
const AUTO_LOCK_MS = 5 * 60 * 1000;
const LOCK_WARNING_MS = 30 * 1000;
// a plaintext-stored key also starts 'locked' - init() unlocks it
// immediately without a password, keeping a single code path for deriving
// the AES key and loading bearers
const initialState = (): WalletState => (savedKeyExists() ? 'locked' : 'none')
const initialState = (): WalletState => (savedKeyExists() ? 'locked' : 'none');
export const useWalletStore = defineStore('wallet', () => {
const state = ref<WalletState>(initialState())
const bearers = ref<Bearer[]>([])
const pubkey = ref<string | null>(null)
let aesKey: CryptoKey | null = null
const state = ref<WalletState>(initialState());
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
let currentLinkingKey: Uint8Array | null = null;
// ---- idle auto-lock bookkeeping ----
let lastActivity = Date.now()
let idleTimer: ReturnType<typeof setInterval> | null = null
const lockWarningSecondsLeft = ref<number | null>(null)
let lastActivity = Date.now();
let idleTimer: ReturnType<typeof setInterval> | null = null;
const lockWarningSecondsLeft = ref<number | null>(null);
const encrypted = computed(() => savedKeyIsEncrypted())
const encrypted = computed(() => savedKeyIsEncrypted());
// ---- balances: protocol layer is msat; sats are a display helper ----
const unspentBearers = computed(() => bearers.value.filter(b => !b.spent))
const balanceMsat = computed(() =>
unspentBearers.value.reduce((sum, b) => sum + b.amount, 0)
)
const balanceSats = computed(() => msatToSats(balanceMsat.value))
const unspentBearers = computed(() => bearers.value.filter((b) => !b.spent));
const balanceMsat = computed(() => unspentBearers.value.reduce((sum, b) => sum + b.amount, 0));
const balanceSats = computed(() => msatToSats(balanceMsat.value));
const balanceByMintMsat = computed(() => {
const byMint = new Map<string, number>()
const byMint = new Map<string, number>();
for (const b of unspentBearers.value) {
const server = serverOf(b.url)
byMint.set(server, (byMint.get(server) ?? 0) + b.amount)
const server = serverOf(b.url);
byMint.set(server, (byMint.get(server) ?? 0) + b.amount);
}
return byMint
})
return byMint;
});
const balanceByMintSats = computed(() => {
const byMint = new Map<string, number>()
const byMint = new Map<string, number>();
for (const [server, msat] of balanceByMintMsat.value) {
byMint.set(server, msatToSats(msat))
byMint.set(server, msatToSats(msat));
}
return byMint
})
return byMint;
});
const stopIdleWatch = () => {
if (idleTimer) clearInterval(idleTimer)
idleTimer = null
lockWarningSecondsLeft.value = null
}
if (idleTimer) clearInterval(idleTimer);
idleTimer = null;
lockWarningSecondsLeft.value = null;
};
const lock = () => {
// only meaningful for a password-encrypted key - a plaintext one would
// 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()
stopIdleWatch()
state.value = 'locked'
}
if (!savedKeyIsEncrypted()) return;
aesKey = null;
currentLinkingKey = null;
pubkey.value = null;
bearers.value = [];
useActivityStore().unload();
stopIdleWatch();
state.value = 'locked';
};
// any real interaction restarts the 5-minute clock and clears the
// warning - the UI's "Stay unlocked" button calls this
const postponeLock = () => {
lastActivity = Date.now()
lockWarningSecondsLeft.value = null
}
lastActivity = Date.now();
lockWarningSecondsLeft.value = null;
};
// ticks once a second while unlocked and encrypted (the only state
// auto-lock applies to), comparing wall-clock time against lastActivity
@@ -123,40 +122,40 @@ export const useWalletStore = defineStore('wallet', () => {
// throttles timers but Date.now() still reflects real elapsed time
// whenever this next gets to run
const startIdleWatch = () => {
stopIdleWatch()
if (typeof window === 'undefined' || !savedKeyIsEncrypted()) return
lastActivity = Date.now()
stopIdleWatch();
if (typeof window === 'undefined' || !savedKeyIsEncrypted()) return;
lastActivity = Date.now();
const registerActivity = () => {
// once the warning is up, passive activity is deliberately ignored -
// only postponeLock() dismisses it, so the "stay unlocked" button
// can't vanish out from under the pointer before the click lands
if (state.value === 'unlocked' && lockWarningSecondsLeft.value === null) {
lastActivity = Date.now()
}
lastActivity = Date.now();
}
};
for (const event of ['mousemove', 'mousedown', 'keydown', 'touchstart', 'scroll']) {
window.addEventListener(event, registerActivity, {passive: true})
window.addEventListener(event, registerActivity, { passive: true });
}
idleTimer = setInterval(() => {
if (state.value !== 'unlocked') return
const elapsed = Date.now() - lastActivity
if (state.value !== 'unlocked') return;
const elapsed = Date.now() - lastActivity;
if (elapsed >= AUTO_LOCK_MS) {
lock()
lock();
} else if (elapsed >= AUTO_LOCK_MS - LOCK_WARNING_MS) {
lockWarningSecondsLeft.value = Math.ceil((AUTO_LOCK_MS - elapsed) / 1000)
}
}, 1000)
lockWarningSecondsLeft.value = Math.ceil((AUTO_LOCK_MS - elapsed) / 1000);
}
}, 1000);
};
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
const activity = useActivityStore()
await activity.loadFor(key)
const key = await deriveBearerAesKey(linkingKey);
aesKey = key;
currentLinkingKey = linkingKey;
pubkey.value = linkingPubKeyHex(linkingKey);
const loaded = await loadBearers(key);
bearers.value = loaded;
const activity = useActivityStore();
await activity.loadFor(key);
// grandfather in every mint already backing a held bearer as trusted -
// holding funds there already implied trusting it. Storage-sourced
// claims only, though: grandfathering never locks and marks new pins
@@ -164,55 +163,58 @@ export const useWalletStore = defineStore('wallet', () => {
// bearer operations
for (const bearer of loaded) {
if (bearer.mintPubkey) {
grandfatherTrustedMint(serverOf(bearer.url), bearer.mintPubkey)
grandfatherTrustedMint(serverOf(bearer.url), bearer.mintPubkey);
}
}
state.value = 'unlocked'
startIdleWatch()
}
state.value = 'unlocked';
startIdleWatch();
};
// generates a fresh seed phrase, derives and saves the linking key, and
// unlocks. The phrase is returned exactly once - it is never stored, so
// the caller MUST show it to the holder before letting them move on.
const create = async (password?: string): Promise<string> => {
const phrase = generateSeedPhrase()
await restoreFromSeed(phrase, password)
return phrase
}
const phrase = generateSeedPhrase();
await restoreFromSeed(phrase, password);
return phrase;
};
const restoreFromSeed = async (
seedPhrase: string,
password?: string
): Promise<void> => {
const restoreFromSeed = async (seedPhrase: string, password?: string): Promise<void> => {
if (!isValidSeedPhrase(seedPhrase)) {
throw new Error('Not a valid seed phrase.')
}
const linkingKey = deriveWalletLinkingKey(seedPhrase)
await saveLinkingKey(linkingKey, password)
await activate(linkingKey)
throw new Error('Not a valid seed phrase.');
}
const linkingKey = deriveWalletLinkingKey(seedPhrase);
await saveLinkingKey(linkingKey, password);
await activate(linkingKey);
};
const unlock = async (password?: string): Promise<void> => {
const linkingKey = savedKeyIsEncrypted()
? await decryptSavedLinkingKey(password || '')
: getPlainLinkingKey()
if (!linkingKey) throw new Error('No wallet on this device.')
await activate(linkingKey)
}
: getPlainLinkingKey();
if (!linkingKey) throw new Error('No wallet on this device.');
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())
}
await activate(await unlockWithPasskeyEngine());
};
// native biometric unlock (capabilities/biometricUnlock.ts): a third wrap
// of the same linking key, behind the device credential prompt
const unlockWithBiometric = async (): Promise<void> => {
await activate(await unlockWithBiometrics());
};
// 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> => {
if (state.value === 'locked' && !savedKeyIsEncrypted()) {
await unlock()
}
await unlock();
}
};
// wipes this wallet from the device entirely - the linking key, every
// bearer record, the activity log, and the non-secret registries that
@@ -221,31 +223,36 @@ export const useWalletStore = defineStore('wallet', () => {
// gone); only a backup downloaded before this runs can bring the notes
// back - the UI should prompt for one
const forgetWallet = () => {
clearSavedLinkingKey()
clearAllBearers()
clearTrustedMints()
clearSettings()
useActivityStore().unloadAndClear()
aesKey = null
currentLinkingKey = null
pubkey.value = null
bearers.value = []
stopIdleWatch()
state.value = 'none'
}
clearSavedLinkingKey();
// the biometric wrap belongs to this wallet's linking key - drop it too.
// The record removal is synchronous inside; the secure-storage delete
// trails behind, and an orphaned secret there is unusable without the
// record, so a failed delete is safe to swallow
void disableBiometricUnlock().catch(() => {});
clearAllBearers();
clearTrustedMints();
clearSettings();
useActivityStore().unloadAndClear();
aesKey = null;
currentLinkingKey = null;
pubkey.value = null;
bearers.value = [];
stopIdleWatch();
state.value = 'none';
};
const requireKey = (): CryptoKey => {
if (!aesKey) throw new Error('Wallet is locked.')
return aesKey
}
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 => {
if (!currentLinkingKey) throw new Error('Wallet is locked.')
return currentLinkingKey
}
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
@@ -254,64 +261,61 @@ export const useWalletStore = defineStore('wallet', () => {
// 'rekey-pending' and is staged on the mints store for review, never
// auto-applied.
const addBearers = async (notes: NewBearer[]): Promise<Bearer[]> => {
const now = Date.now()
const added: Bearer[] = []
const now = Date.now();
const added: Bearer[] = [];
for (const note of notes) {
const bearer: Bearer = {
id: newBearerId(),
...note,
createdAt: now,
updatedAt: now
updatedAt: now,
};
await persistBearer(requireKey(), bearer);
added.push(bearer);
}
await persistBearer(requireKey(), bearer)
added.push(bearer)
}
bearers.value = [...added, ...bearers.value]
bearers.value = [...added, ...bearers.value];
for (const bearer of added) {
if (bearer.mintPubkey) {
lockTrustedMint(serverOf(bearer.url), bearer.mintPubkey)
lockTrustedMint(serverOf(bearer.url), bearer.mintPubkey);
}
}
return added
}
return added;
};
const updateBearer = async (
id: string,
changes: Partial<Omit<Bearer, 'id'>>
): Promise<void> => {
const current = bearers.value.find(b => b.id === id)
if (!current) return
const updated: Bearer = {...current, ...changes, updatedAt: Date.now()}
await persistBearer(requireKey(), updated)
bearers.value = bearers.value.map(b => (b.id === id ? updated : b))
const updateBearer = async (id: string, changes: Partial<Omit<Bearer, 'id'>>): Promise<void> => {
const current = bearers.value.find((b) => b.id === id);
if (!current) return;
const updated: Bearer = { ...current, ...changes, updatedAt: Date.now() };
await persistBearer(requireKey(), updated);
bearers.value = bearers.value.map((b) => (b.id === id ? updated : b));
if (updated.mintPubkey) {
lockTrustedMint(serverOf(updated.url), updated.mintPubkey)
}
lockTrustedMint(serverOf(updated.url), updated.mintPubkey);
}
};
const markSpent = async (id: string, spent = true): Promise<void> => {
await updateBearer(id, {spent})
}
await updateBearer(id, { spent });
};
const removeNote = async (id: string): Promise<void> => {
bearers.value = bearers.value.filter(b => b.id !== id)
await deleteBearerRecord(id)
}
bearers.value = bearers.value.filter((b) => b.id !== id);
await deleteBearerRecord(id);
};
// merges externally-produced bearers (a decrypted backup restore, later a
// nostr restore) into the live list: union by note identity, spent-wins -
// see storage.ts's mergeBearers. Persists every survivor.
const mergeExternalBearers = async (incoming: Bearer[]): Promise<void> => {
const merged = mergeBearers(bearers.value, incoming)
const merged = mergeBearers(bearers.value, incoming);
for (const bearer of merged) {
await persistBearer(requireKey(), bearer)
}
bearers.value = merged
await persistBearer(requireKey(), bearer);
}
bearers.value = merged;
};
const reloadBearers = async (): Promise<void> => {
bearers.value = await loadBearers(requireKey())
}
bearers.value = await loadBearers(requireKey());
};
return {
state,
@@ -328,6 +332,7 @@ export const useWalletStore = defineStore('wallet', () => {
restoreFromSeed,
unlock,
unlockWithPasskey,
unlockWithBiometric,
lock,
init,
forgetWallet,
@@ -338,6 +343,6 @@ export const useWalletStore = defineStore('wallet', () => {
markSpent,
removeNote,
mergeExternalBearers,
reloadBearers
}
})
reloadBearers,
};
});