stacker.news/components/use-vault.js

488 lines
15 KiB
JavaScript
Raw Normal View History

Encrypted device sync (#1373) * user vault * code cleanup and fixes * improve ui * prevent name collisions between users on the same device * some improvements * implement storage migration * comments and cleanup * make connect button primary instead of warning * move show passphrase in new line (improvement for small screen devices) * make show passphrase field readOnly * fixes * fix vault key unsync * implicit migration * move device sync under general tab * fix locally disabled wallets and default wallet selection * improve text * remove useless SSR check * add auth checks * Rename variables * Fix missing await * Refactor local<>vault storage interface I've changed quite some things here. Attempt of a summary: * storageKey is now only controlled by useVaultStorageState I've noticed that dealing with how storage keys are generated (to apply user scope) was handled in two places: the existing wallet code and in the new vault code. This was confusing and error-prone. I've fixed that by completely relying on the new vault code to generate correct storage keys. * refactored migration Migration now simply encrypts any existing local wallets and sends them to the server. On success, the local unencrypted version is deleted. The previous code seemed to unnecessarily generate new local entries prefixed by 'vault:'. However, since we either use unencrypted local state OR use the encrypted vault on the server for the data, I didn't see any need for these. Migration seems to work just as well as before. * removed unnecessary state In the <DeviceSync> component, enabled & connected were using a unnecessary combo of useState+useEffect. They were only using variables that are always available during render so simple assignments were enough. * other minor changes include: * early returns * remove unnecessary SSR checks in useEffect or useCallback * formatting, comments * remove unnecessary me? to expose possible bugs * Fix missing dependency for useZap This didn't cause any bugs because useWallet returns everything we need on first render. This caused a bug with E2EE device sync branch though since there the wallet is loaded async. This meant that during payment, the wallet config was undefined. * Assume JSON during encryption and decryption * Fix stale value from cache served on next fetches * Add wallet.perDevice field This adds 'perDevice' as a new wallet field to force local storage. For example, WebLN should not be synced across devices. * Remove debug buttons * Rename userVault -> vault * Update console.log's * revert some of the migration and key handling changes. restore debug buttons for testing * Fix existing wallets not loaded * Pass in localOnly and generate localStorageKey once * Small refactor of migration * Fix wallet drag and drop * Add passphrase copy button * Fix priorityOnly -> skipTests * Disable autocompletion for reset confirmation prompt * Show wrong passphrase as input error * Move code into components/device-sync.js * Import/export passphrase via QR code * Fix modal back button invisible in light mode * Fix modal closed even on connect error * Use me-2 for cancel/close button * Some rephrasing * Fix wallet detach * Remove debug buttons * Fix QR code scan in dark mode * Don't allow custom passphrases * More rephrasing * Only use schema if not enabled * Fix typo in comment * Replace 'generate passphrase' button with reload icon * Add comment about IV reuse in GCM * Use 600k iterations as recommended by OWASP * Set extractable to false where not needed * use-vault fallbacks to local storage only for anonymous users * fix localStorage reset on logout * add copy button * move reset out of modals * hide server side errors * hardened passphrase storage * do not show passphrase even if hardened storage is disabled (ie. indexeddb not supported) * show qr code button on passphrase creation * use toast for serverside error * Move key (de)serialization burden to get/setLocalKey functions * password textarea and remove qr * don't print plaintext vault values into console --------- Co-authored-by: ekzyis <ek@stacker.news> Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com> Co-authored-by: k00b <k00b@stacker.news>
2024-10-01 19:55:01 +00:00
import { useCallback, useState, useEffect } from 'react'
import { useMe } from '@/components/me'
import { useMutation, useQuery } from '@apollo/client'
import { GET_ENTRY, SET_ENTRY, UNSET_ENTRY, CLEAR_VAULT, SET_VAULT_KEY_HASH } from '@/fragments/vault'
import { E_VAULT_KEY_EXISTS } from '@/lib/error'
import { SSR } from '@/lib/constants'
import { useToast } from '@/components/toast'
const USE_INDEXEDDB = true
export function useVaultConfigurator () {
const { me } = useMe()
const [setVaultKeyHash] = useMutation(SET_VAULT_KEY_HASH)
const toaster = useToast()
// vault key stored locally
const [vaultKey, innerSetVaultKey] = useState(null)
useEffect(() => {
if (!me) return
(async () => {
let localVaultKey = await getLocalKey(me.id)
if (!me.privates.vaultKeyHash || localVaultKey?.hash !== me.privates.vaultKeyHash) {
// We can tell that another device has reset the vault if the values
// on the server are encrypted with a different key or no key exists anymore.
// In that case, our local key is no longer valid and our device needs to be connected
// to the vault again by entering the correct passphrase.
console.log('vault key hash mismatch, clearing local key', localVaultKey, me.privates.vaultKeyHash)
localVaultKey = null
await unsetLocalKey(me.id)
}
innerSetVaultKey(localVaultKey)
})()
}, [me?.privates?.vaultKeyHash])
// clear vault: remove everything and reset the key
const [clearVault] = useMutation(CLEAR_VAULT, {
onCompleted: async () => {
await unsetLocalKey(me.id)
innerSetVaultKey(null)
}
})
// initialize the vault and set a vault key
const setVaultKey = useCallback(async (passphrase) => {
const vaultKey = await deriveKey(me.id, passphrase)
await setVaultKeyHash({
variables: { hash: vaultKey.hash },
onError: (error) => {
const errorCode = error.graphQLErrors[0]?.extensions?.code
if (errorCode === E_VAULT_KEY_EXISTS) {
throw new Error('wrong passphrase')
}
toaster.danger(error.graphQLErrors[0].message)
}
})
innerSetVaultKey(vaultKey)
await setLocalKey(me.id, vaultKey)
}, [setVaultKeyHash])
// disconnect the user from the vault (will not clear or reset the passphrase, use clearVault for that)
const disconnectVault = useCallback(async () => {
await unsetLocalKey(me.id)
innerSetVaultKey(null)
}, [innerSetVaultKey])
return [vaultKey, setVaultKey, clearVault, disconnectVault]
}
export function useVaultMigration () {
const { me } = useMe()
const [setVaultEntry] = useMutation(SET_ENTRY)
// migrate local storage to vault
const migrate = useCallback(async () => {
const vaultKey = await getLocalKey(me.id)
if (!vaultKey) throw new Error('vault key not found')
let migratedCount = 0
for (const migratableKey of retrieveMigratableKeys(me.id)) {
try {
const value = JSON.parse(window.localStorage.getItem(migratableKey.localStorageKey))
if (!value) throw new Error('no value found in local storage')
const encrypted = await encryptJSON(vaultKey, value)
const { data } = await setVaultEntry({ variables: { key: migratableKey.vaultStorageKey, value: encrypted, skipIfSet: true } })
if (data?.setVaultEntry) {
window.localStorage.removeItem(migratableKey.localStorageKey)
migratedCount++
console.log('migrated to vault:', migratableKey)
} else {
throw new Error('could not set vault entry')
}
} catch (e) {
console.error('failed migrate to vault:', migratableKey, e)
}
}
return migratedCount
}, [me?.id])
return migrate
}
// used to get and set values in the vault
export default function useVault (vaultStorageKey, defaultValue, options = { localOnly: false }) {
const { me } = useMe()
const localOnly = options.localOnly || !me
// This is the key that we will use in local storage whereas vaultStorageKey is the key that we
// will use on the server ("the vault").
const localStorageKey = getLocalStorageKey(vaultStorageKey, me?.id, localOnly)
const [setVaultValue] = useMutation(SET_ENTRY)
const [value, innerSetValue] = useState(undefined)
const [clearVaultValue] = useMutation(UNSET_ENTRY)
const { data: vaultData, refetch: refetchVaultValue } = useQuery(GET_ENTRY, {
variables: { key: vaultStorageKey },
// fetchPolicy only applies to first execution on mount so we also need to
// set nextFetchPolicy to make sure we don't serve stale values from cache
nextFetchPolicy: 'no-cache',
fetchPolicy: 'no-cache'
})
useEffect(() => {
(async () => {
if (localOnly) {
innerSetValue((await getLocalStorage(localStorageKey)) || defaultValue)
return
}
const localVaultKey = await getLocalKey(me?.id)
if (!me.privates.vaultKeyHash || localVaultKey?.hash !== me.privates.vaultKeyHash) {
// no or different vault setup on server
// use unencrypted local storage
await unsetLocalKey(me.id)
innerSetValue((await getLocalStorage(localStorageKey)) || defaultValue)
return
}
// if vault key hash is set on the server, vault entry exists and vault key is set on the device
// decrypt and use the value from the server
const encrypted = vaultData?.getVaultEntry?.value
if (encrypted) {
try {
const decrypted = await decryptJSON(localVaultKey, encrypted)
// console.log('decrypted value from vault:', storageKey, encrypted, decrypted)
innerSetValue(decrypted)
// remove local storage value if it exists
await unsetLocalStorage(localStorageKey)
return
} catch (e) {
console.error('cannot read vault data:', vaultStorageKey, e)
}
}
// fallback to local storage
innerSetValue((await getLocalStorage(localStorageKey)) || defaultValue)
})()
}, [vaultData, me?.privates?.vaultKeyHash, localOnly])
const setValue = useCallback(async (newValue) => {
const vaultKey = await getLocalKey(me?.id)
const useVault = vaultKey && vaultKey.hash === me.privates.vaultKeyHash
if (useVault && !localOnly) {
const encryptedValue = await encryptJSON(vaultKey, newValue)
await setVaultValue({ variables: { key: vaultStorageKey, value: encryptedValue } })
console.log('stored encrypted value in vault:', vaultStorageKey, encryptedValue)
// clear local storage (we get rid of stored unencrypted data as soon as it can be stored on the vault)
await unsetLocalStorage(localStorageKey)
} else {
console.log('stored value in local storage:', localStorageKey, newValue)
// otherwise use local storage
await setLocalStorage(localStorageKey, newValue)
}
// refresh in-memory value
innerSetValue(newValue)
}, [me?.privates?.vaultKeyHash, localStorageKey, vaultStorageKey, localOnly])
const clearValue = useCallback(async ({ onlyFromLocalStorage }) => {
// unset a value
// clear server
if (!localOnly && !onlyFromLocalStorage) {
await clearVaultValue({ variables: { key: vaultStorageKey } })
await refetchVaultValue()
}
// clear local storage
await unsetLocalStorage(localStorageKey)
// clear in-memory value
innerSetValue(undefined)
}, [vaultStorageKey, localStorageKey, localOnly])
return [value, setValue, clearValue, refetchVaultValue]
}
function retrieveMigratableKeys (userId) {
// get all the local storage keys that can be migrated
const out = []
for (const key of Object.keys(window.localStorage)) {
if (key.includes(':local-only:')) continue
if (!key.endsWith(`:${userId}`)) continue
if (key.startsWith('vault:')) {
out.push({
vaultStorageKey: key.substring('vault:'.length, key.length - `:${userId}`.length),
localStorageKey: key
})
}
// required for backwards compatibility with keys that were stored before we had the vault
if (key.startsWith('wallet:')) {
out.push({
vaultStorageKey: key.substring(0, key.length - `:${userId}`.length),
localStorageKey: key
})
}
}
return out
}
async function getLocalStorageBackend (useIndexedDb) {
if (SSR) return null
if (USE_INDEXEDDB && useIndexedDb && window.indexedDB && !window.snVaultIDB) {
try {
const storage = await new Promise((resolve, reject) => {
const db = window.indexedDB.open('sn-vault', 1)
db.onupgradeneeded = (event) => {
const db = event.target.result
db.createObjectStore('vault', { keyPath: 'key' })
}
db.onsuccess = () => {
if (!db?.result?.transaction) reject(new Error('unsupported implementation'))
else resolve(db.result)
}
db.onerror = reject
})
window.snVaultIDB = storage
} catch (e) {
console.error('could not use indexedDB:', e)
}
}
const isIDB = useIndexedDb && !!window.snVaultIDB
return {
isIDB,
set: async (key, value) => {
if (isIDB) {
const tx = window.snVaultIDB.transaction(['vault'], 'readwrite')
const objectStore = tx.objectStore('vault')
objectStore.add({ key, value })
await new Promise((resolve, reject) => {
tx.oncomplete = resolve
tx.onerror = reject
})
} else {
window.localStorage.setItem(key, JSON.stringify(value))
}
},
get: async (key) => {
if (isIDB) {
const tx = window.snVaultIDB.transaction(['vault'], 'readonly')
const objectStore = tx.objectStore('vault')
const request = objectStore.get(key)
return await new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result?.value)
request.onerror = reject
})
} else {
const v = window.localStorage.getItem(key)
return v ? JSON.parse(v) : null
}
},
clear: async (key) => {
if (isIDB) {
const tx = window.snVaultIDB.transaction(['vault'], 'readwrite')
const objectStore = tx.objectStore('vault')
objectStore.delete(key)
await new Promise((resolve, reject) => {
tx.oncomplete = resolve
tx.onerror = reject
})
} else {
window.localStorage.removeItem(key)
}
}
}
}
function getLocalStorageKey (key, userId, localOnly) {
if (!userId) userId = 'anon'
// We prefix localStorageKey with 'vault:' so we know which
// keys we need to migrate to the vault when device sync is enabled.
let localStorageKey = `vault:${key}`
// wallets like WebLN don't make sense to share across devices since they rely on a browser extension.
// We check for this ':local-only:' tag during migration to skip any keys that contain it.
if (localOnly) {
localStorageKey = `vault:local-only:${key}`
}
// always scope to user to avoid messing with wallets of other users on same device that might exist
return `${localStorageKey}:${userId}`
}
async function setLocalKey (userId, localKey) {
if (SSR) return
if (!userId) userId = 'anon'
const storage = await getLocalStorageBackend(true)
const k = `vault-key:local-only:${userId}`
const { key, hash } = localKey
const rawKey = await window.crypto.subtle.exportKey('raw', key)
if (storage.isIDB) {
let nonExtractableKey
// if IDB, we ensure the key is non extractable
if (localKey.extractable) {
nonExtractableKey = await window.crypto.subtle.importKey(
'raw',
rawKey,
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt']
)
} else {
nonExtractableKey = localKey.key
}
// and we store it
return await storage.set(k, { key: nonExtractableKey, hash, extractable: false })
} else {
// if non IDB we need to serialize the key to store it
const keyHex = toHex(rawKey)
return await storage.set(k, { key: keyHex, hash, extractable: true })
}
}
async function getLocalKey (userId) {
if (SSR) return null
if (!userId) userId = 'anon'
const storage = await getLocalStorageBackend(true)
const key = await storage.get(`vault-key:local-only:${userId}`)
if (!key) return null
if (!storage.isIDB) {
// if non IDB we need to deserialize the key
const rawKey = fromHex(key.key)
const keyMaterial = await window.crypto.subtle.importKey(
'raw',
rawKey,
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt']
)
key.key = keyMaterial
key.extractable = true
}
return key
}
export async function unsetLocalKey (userId) {
if (SSR) return
if (!userId) userId = 'anon'
const storage = await getLocalStorageBackend(true)
return await storage.clear(`vault-key:local-only:${userId}`)
}
async function setLocalStorage (key, value) {
if (SSR) return
const storage = await getLocalStorageBackend(false)
await storage.set(key, value)
}
async function getLocalStorage (key) {
if (SSR) return null
const storage = await getLocalStorageBackend(false)
let v = await storage.get(key)
// ensure backwards compatible with wallet keys that we used before we had the vault
if (!v) {
const oldKey = key.replace(/vault:(local-only:)?/, '')
v = await storage.get(oldKey)
}
return v
}
async function unsetLocalStorage (key) {
if (SSR) return
const storage = await getLocalStorageBackend(false)
await storage.clear(key)
}
function toHex (buffer) {
const byteArray = new Uint8Array(buffer)
const hexString = Array.from(byteArray, byte => byte.toString(16).padStart(2, '0')).join('')
return hexString
}
function fromHex (hex) {
const byteArray = new Uint8Array(hex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)))
return byteArray.buffer
}
async function deriveKey (userId, passphrase) {
const enc = new TextEncoder()
const keyMaterial = await window.crypto.subtle.importKey(
'raw',
enc.encode(passphrase),
{ name: 'PBKDF2' },
false,
['deriveKey']
)
const key = await window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: enc.encode(`stacker${userId}`),
// 600,000 iterations is recommended by OWASP
// see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2
iterations: 600_000,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
)
const rawKey = await window.crypto.subtle.exportKey('raw', key)
const rawHash = await window.crypto.subtle.digest('SHA-256', rawKey)
return {
key,
hash: toHex(rawHash),
extractable: true
}
}
async function encryptJSON (localKey, jsonData) {
const { key } = localKey
// random IVs are _really_ important in GCM: reusing the IV once can lead to catastrophic failure
// see https://crypto.stackexchange.com/questions/26790/how-bad-it-is-using-the-same-iv-twice-with-aes-gcm
const iv = window.crypto.getRandomValues(new Uint8Array(12))
const encoded = new TextEncoder().encode(JSON.stringify(jsonData))
const encrypted = await window.crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv
},
key,
encoded
)
return JSON.stringify({
iv: toHex(iv.buffer),
data: toHex(encrypted)
})
}
async function decryptJSON (localKey, encryptedData) {
const { key } = localKey
let { iv, data } = JSON.parse(encryptedData)
iv = fromHex(iv)
data = fromHex(data)
const decrypted = await window.crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv
},
key,
data
)
const decoded = new TextDecoder().decode(decrypted)
return JSON.parse(decoded)
}