add password-based unlock as fallback when Touch ID unavailable; PBKDF2 salted hash stored in keychain
This commit is contained in:
@@ -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,6 +114,7 @@
|
|||||||
<CardDescription>Your seed is stored in the system keychain</CardDescription>
|
<CardDescription>Your seed is stored in the system keychain</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
{#if w.biometricAvailable}
|
{#if w.biometricAvailable}
|
||||||
<Button
|
<Button
|
||||||
onclick={() => w.unlockWithBiometrics()}
|
onclick={() => w.unlockWithBiometrics()}
|
||||||
@@ -114,11 +122,27 @@
|
|||||||
<Fingerprint size={16} />
|
<Fingerprint size={16} />
|
||||||
{w.loading ? 'Unlocking...' : 'Unlock with Touch ID'}
|
{w.loading ? 'Unlocking...' : 'Unlock with Touch ID'}
|
||||||
</Button>
|
</Button>
|
||||||
{:else}
|
|
||||||
<p class="text-muted-foreground text-xs">
|
|
||||||
Touch ID not available on this device
|
|
||||||
</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if w.passwordSet}
|
||||||
|
<div class="flex gap-2 items-end">
|
||||||
|
<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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user