mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: native biometric unlock via biometric-gated wrap of the linking key
This commit is contained in:
+160
-155
@@ -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)
|
||||
lockWarningSecondsLeft.value = Math.ceil((AUTO_LOCK_MS - elapsed) / 1000);
|
||||
}
|
||||
}, 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.')
|
||||
throw new Error('Not a valid seed phrase.');
|
||||
}
|
||||
const linkingKey = deriveWalletLinkingKey(seedPhrase)
|
||||
await saveLinkingKey(linkingKey, password)
|
||||
await activate(linkingKey)
|
||||
}
|
||||
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
|
||||
}
|
||||
await persistBearer(requireKey(), bearer)
|
||||
added.push(bearer)
|
||||
updatedAt: now,
|
||||
};
|
||||
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)
|
||||
await persistBearer(requireKey(), bearer);
|
||||
}
|
||||
bearers.value = merged
|
||||
}
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user