add password-based unlock as fallback when Touch ID unavailable; PBKDF2 salted hash stored in keychain

This commit is contained in:
2026-05-18 04:00:49 +08:00
parent 7eddbc252e
commit fc81e0889a
2 changed files with 99 additions and 23 deletions
@@ -50,6 +50,8 @@
const w = wallet const w = wallet
let inputSeed = $state('') let inputSeed = $state('')
let inputPassword = $state('')
let inputUnlockPassword = $state('')
let apiKeyInput = $state('') let apiKeyInput = $state('')
let qrDataUrl = $state('') let qrDataUrl = $state('')
let qrDialogOpen = $state(false) let qrDialogOpen = $state(false)
@@ -89,8 +91,13 @@
<Textarea <Textarea
placeholder="Enter your 12 or 24 word seed phrase" placeholder="Enter your 12 or 24 word seed phrase"
bind:value={inputSeed} /> bind:value={inputSeed} />
<input
type="password"
placeholder="Set a password (optional, used when Touch ID is unavailable)"
class="flex h-8 w-full rounded-md border border-input bg-transparent px-2 py-1 font-mono text-xs"
bind:value={inputPassword} />
<Button <Button
onclick={() => w.saveVault(inputSeed)} onclick={() => w.saveVault(inputSeed, inputPassword || undefined)}
disabled={w.loading || !inputSeed.trim()}> disabled={w.loading || !inputSeed.trim()}>
{w.loading ? 'Saving...' : 'Save to Keychain'} {w.loading ? 'Saving...' : 'Save to Keychain'}
</Button> </Button>
@@ -107,18 +114,35 @@
<CardDescription>Your seed is stored in the system keychain</CardDescription> <CardDescription>Your seed is stored in the system keychain</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{#if w.biometricAvailable} <div class="flex flex-col gap-3">
<Button {#if w.biometricAvailable}
onclick={() => w.unlockWithBiometrics()} <Button
disabled={w.loading}> onclick={() => w.unlockWithBiometrics()}
<Fingerprint size={16} /> disabled={w.loading}>
{w.loading ? 'Unlocking...' : 'Unlock with Touch ID'} <Fingerprint size={16} />
</Button> {w.loading ? 'Unlocking...' : 'Unlock with Touch ID'}
{:else} </Button>
<p class="text-muted-foreground text-xs"> {/if}
Touch ID not available on this device {#if w.passwordSet}
</p> <div class="flex gap-2 items-end">
{/if} <input
type="password"
placeholder="Enter password"
class="flex h-8 w-full rounded-md border border-input bg-transparent px-2 py-1 font-mono text-xs"
bind:value={inputUnlockPassword} />
<Button
onclick={async () => {
const ok = await w.unlockWithPassword(inputUnlockPassword)
if (!ok) w.error = 'Wrong password'
inputUnlockPassword = ''
}}
disabled={w.loading || !inputUnlockPassword}
size="sm">
{w.loading ? '...' : 'Unlock'}
</Button>
</div>
{/if}
</div>
{#if w.error} {#if w.error}
<p class="mt-3 text-red-500">{w.error}</p> <p class="mt-3 text-red-500">{w.error}</p>
{/if} {/if}
+62 -10
View File
@@ -81,9 +81,19 @@ export const Wallet = () => {
let moneroWalletName = $state('') let moneroWalletName = $state('')
let moneroAccounts = $state<MoneroAccountEntry[]>([]) let moneroAccounts = $state<MoneroAccountEntry[]>([])
let moneroWallets = $state<string[]>([]) let moneroWallets = $state<string[]>([])
let passwordHash = $state('')
const net = () => networks.find((n) => n.id === network)! const net = () => networks.find((n) => n.id === network)!
async function hashPassword(password: string, salt?: string): Promise<{ salt: string; hash: string }> {
const s = salt ?? Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('')
const enc = new TextEncoder()
const key = await crypto.subtle.importKey('raw', enc.encode(password + s), 'PBKDF2', false, ['deriveBits'])
const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt: enc.encode(s), iterations: 100_000, hash: 'SHA-256' }, key, 256)
const hash = Array.from(new Uint8Array(bits), b => b.toString(16).padStart(2, '0')).join('')
return { salt: s, hash }
}
const isMonero = () => network === 'monero' const isMonero = () => network === 'monero'
const init = async () => { const init = async () => {
@@ -100,18 +110,18 @@ export const Wallet = () => {
} }
vaultExists = raw !== null && raw !== undefined vaultExists = raw !== null && raw !== undefined
const [key, keyError] = await tryCatch( const [key, keyError] = await tryCatch(
electrobun.rpc?.request.getSecret({ electrobun.rpc.request.getSecret({ service: 'koins', name: 'alchemy_key' }),
service: 'koins',
name: 'alchemy_key',
}),
) )
if (keyError) console.log('key error', keyError)
if (keyError) {
console.log('key error', keyError)
}
if (key) apiKey = key if (key) apiKey = key
const [ph] = await tryCatch(
electrobun.rpc.request.getSecret({ service: 'koins', name: 'vault_password' }),
)
if (ph) passwordHash = ph
await checkBiometric() await checkBiometric()
ready = true ready = true
} }
@@ -130,7 +140,7 @@ export const Wallet = () => {
} }
} }
const saveVault = async (phrase: string) => { const saveVault = async (phrase: string, password?: string) => {
loading = true loading = true
error = '' error = ''
try { try {
@@ -140,6 +150,16 @@ export const Wallet = () => {
name: 'vault', name: 'vault',
value: phrase.trim(), value: phrase.trim(),
}) })
if (password) {
const ph = await hashPassword(password)
const raw = JSON.stringify(ph)
await electrobun.rpc?.request.setSecret({
service: 'koins',
name: 'vault_password',
value: raw,
})
passwordHash = raw
}
seed = phrase.trim() seed = phrase.trim()
vaultExists = true vaultExists = true
await refresh() await refresh()
@@ -164,6 +184,35 @@ export const Wallet = () => {
electrobun.rpc.request.biometricAuth({ reason: 'Unlock wallet' }), electrobun.rpc.request.biometricAuth({ reason: 'Unlock wallet' }),
) )
if (!authed) return false if (!authed) return false
return loadSeed()
}
const unlockWithPassword = async (password: string) => {
if (!passwordHash) return false
try {
const { salt, hash } = JSON.parse(passwordHash)
const { hash: check } = await hashPassword(password, salt)
if (check !== hash) return false
return await loadSeed()
} catch {
return false
}
}
const setPassword = async (password: string) => {
if (!electrobun.rpc) return
const ph = await hashPassword(password)
const raw = JSON.stringify(ph)
await electrobun.rpc.request.setSecret({
service: 'koins',
name: 'vault_password',
value: raw,
})
passwordHash = raw
}
async function loadSeed() {
if (!electrobun.rpc) return false
const [raw] = await tryCatch( const [raw] = await tryCatch(
electrobun.rpc.request.getSecret({ electrobun.rpc.request.getSecret({
service: 'koins', service: 'koins',
@@ -417,10 +466,13 @@ export const Wallet = () => {
get moneroAccounts() { return moneroAccounts }, get moneroAccounts() { return moneroAccounts },
get moneroWallets() { return moneroWallets }, get moneroWallets() { return moneroWallets },
get biometricAvailable() { return biometricAvailable }, get biometricAvailable() { return biometricAvailable },
get passwordSet() { return !!passwordHash },
refresh, refresh,
init, init,
lock, lock,
unlockWithBiometrics, unlockWithBiometrics,
unlockWithPassword,
setPassword,
saveVault, saveVault,
switchNetwork, switchNetwork,
saveApiKey, saveApiKey,