488 lines
15 KiB
JavaScript
488 lines
15 KiB
JavaScript
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)
|
|
}
|