edge-core-js
Version:
Edge account & wallet management library
415 lines (358 loc) • 11.3 kB
JavaScript
function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } }import { asMaybe } from 'cleaners'
import { base16, base64 } from 'rfc4648'
import { wasCreateKeysPayload } from '../../types/server-cleaners'
import { decrypt, decryptText, encrypt } from '../../util/crypto/crypto'
import { hmacSha256 } from '../../util/crypto/hashes'
import { utf8 } from '../../util/encoding'
import { changeWalletStates } from '../account/account-files'
import { waitForCurrencyWallet } from '../currency/currency-selectors'
import {
findCurrencyPluginId,
getCurrencyTools
} from '../plugins/plugins-selectors'
import { getChildStash } from './login-selectors'
import {
asEdgeWalletInfo,
wasEdgeWalletInfo
} from './login-types'
import {
asEdgeStorageKeys,
createStorageKeys,
wasEdgeStorageKeys
} from './storage-keys'
/**
* Returns the first keyInfo with a matching type.
*/
export function findFirstKey(
keyInfos,
type
) {
return keyInfos.find(info => info.type === type)
}
export function makeAccountType(appId) {
return appId === ''
? 'account-repo:co.airbitz.wallet'
: `account-repo:${appId}`
}
/**
* Assembles the key metadata structure that is encrypted within a keyBox.
* @param idKey Used to derive the wallet id. It's usually `dataKey`.
*/
export function makeKeyInfo(
type,
keys,
idKey
) {
const hash = hmacSha256(
utf8.parse(type),
_nullishCoalesce(idKey, () => ( asEdgeStorageKeys(keys).dataKey))
)
return { id: base64.stringify(hash), type, keys }
}
/**
* Assembles all the resources needed to attach new keys to the account.
*/
export function makeKeysKit(
ai,
sessionKey,
keyInfos
) {
// For crash errors:
ai.props.log.breadcrumb('makeKeysKit', {})
const { io } = ai.props
const keyBoxes = keyInfos.map(info => ({
created: new Date(),
...encrypt(
io,
utf8.parse(JSON.stringify(wasEdgeWalletInfo(info))),
sessionKey.loginKey
)
}))
const newSyncKeys = []
for (const info of keyInfos) {
const storageKeys = asMaybe(asEdgeStorageKeys)(info.keys)
if (storageKeys == null) continue
newSyncKeys.push(base16.stringify(storageKeys.syncKey).toLowerCase())
}
return {
loginId: sessionKey.loginId,
server: wasCreateKeysPayload({ keyBoxes, newSyncKeys }),
serverPath: '/v2/login/keys',
stash: { keyBoxes }
}
}
/**
* Flattens an array of key structures, removing duplicates.
*/
export function mergeKeyInfos(keyInfos) {
const out = []
const ids = new Map() // Maps ID's to output array indexes
for (const keyInfo of keyInfos) {
const { id, keys, type } = keyInfo
if (id == null || base64.parse(id).length !== 32) {
throw new Error(`Key integrity violation: invalid id ${id}`)
}
const index = ids.get(id)
if (index != null) {
// We have already seen this id, so check for conflicts:
const old = out[index]
if (old.type !== type) {
throw new Error(
`Key integrity violation for ${id}: type ${type} does not match ${old.type}`
)
}
for (const key of Object.keys(keys)) {
if (old.keys[key] != null && old.keys[key] !== keys[key]) {
throw new Error(
`Key integrity violation for ${id}: ${key} keys do not match`
)
}
}
// Do the update:
out[index] = { id, keys: { ...old.keys, ...keys }, type }
} else {
// We haven't seen this id, so insert it:
ids.set(id, out.length)
out.push(keyInfo)
}
}
return out
}
/**
* Decrypts the private keys contained in a login.
*/
export function decryptKeyInfos(
stash,
loginKey,
keyDates = new Map()
) {
const { appId, keyBoxes = [] } = stash
const legacyKeys = []
// BitID wallet:
const { mnemonicBox, rootKeyBox } = stash
if (mnemonicBox != null && rootKeyBox != null) {
const rootKey = decrypt(rootKeyBox, loginKey)
const infoKey = hmacSha256(rootKey, utf8.parse('infoKey'))
const keys = {
mnemonic: decryptText(mnemonicBox, infoKey),
rootKey: base64.stringify(rootKey)
}
legacyKeys.push(makeKeyInfo('wallet:bitid', keys, rootKey))
}
// Account settings:
if (stash.syncKeyBox != null) {
const syncKey = decrypt(stash.syncKeyBox, loginKey)
const type = makeAccountType(appId)
const keys = wasEdgeStorageKeys({ dataKey: loginKey, syncKey })
legacyKeys.push(makeKeyInfo(type, keys, loginKey))
}
// Keys:
const keyInfos = keyBoxes.map(box => {
const keys = asEdgeWalletInfo(JSON.parse(decryptText(box, loginKey)))
const created = mergeKeyDate(box.created, keyDates.get(keys.id))
if (created != null) keyDates.set(keys.id, created)
return keys
})
return mergeKeyInfos([...legacyKeys, ...keyInfos]).map(walletInfo =>
fixWalletInfo(walletInfo)
)
}
/**
* Returns all the wallet infos accessible from this login object.
*/
export function decryptAllWalletInfos(
stashTree,
sessionKey,
legacyWalletInfos,
walletStates
) {
// Maps from walletId's to appId's:
const dates = new Map()
const appIdMap = new Map()
const walletInfos = [...legacyWalletInfos]
// Navigate to the starting node:
const stash = getChildStash(stashTree, sessionKey.loginId)
// Add the legacy wallets first:
for (const info of legacyWalletInfos) {
walletInfos.push(info)
const appIds = appIdMap.get(info.id)
if (appIds != null) appIds.push(stash.appId)
else appIdMap.set(info.id, [stash.appId])
}
function getAllWalletInfosLoop(
stash,
loginKey
) {
// Add our own walletInfos:
const keyInfos = decryptKeyInfos(stash, loginKey, dates)
for (const info of keyInfos) {
walletInfos.push(info)
const appIds = appIdMap.get(info.id)
if (appIds != null) appIds.push(stash.appId)
else appIdMap.set(info.id, [stash.appId])
}
// Add our children's walletInfos:
for (const child of _nullishCoalesce(stash.children, () => ( []))) {
if (child.parentBox == null) continue
getAllWalletInfosLoop(child, decrypt(child.parentBox, loginKey))
}
}
getAllWalletInfosLoop(stash, sessionKey.loginKey)
return mergeKeyInfos(walletInfos).map(info => {
return {
appId: getLast(_nullishCoalesce(appIdMap.get(info.id), () => ( []))),
appIds: _nullishCoalesce(appIdMap.get(info.id), () => ( [])),
// Defaults to be overwritten:
archived: false,
created: dates.get(info.id),
deleted: false,
hidden: false,
sortIndex: walletInfos.length,
// Copy the `imported` field from the raw keys if it exists
imported: info.keys.imported,
// Actual info:
...walletStates[info.id],
...info
}
})
}
/**
* Upgrades legacy wallet info structures into the new format.
*
* Wallets normally have `wallet:pluginId` as their type,
* but some legacy wallets also put format information into the wallet type.
* This routine moves the information out of the wallet type into the keys.
*
* It also provides some other default values as a historical accident,
* but the bitcoin plugin can just provide its own fallback values if
* `format` or `coinType` are missing. Please don't make the problem worse
* by adding more code here!
*/
export function fixWalletInfo(walletInfo) {
const { id, keys, type } = walletInfo
// Wallet types we need to fix:
const defaults = {
// BTC:
'wallet:bitcoin-bip44': { format: 'bip44', coinType: 0 },
'wallet:bitcoin-bip49': { format: 'bip49', coinType: 0 },
// BCH:
'wallet:bitcoincash-bip32': { format: 'bip32' },
'wallet:bitcoincash-bip44': { format: 'bip44', coinType: 145 },
// BCH testnet:
'wallet:bitcoincash-bip44-testnet': { format: 'bip44', coinType: 1 },
// BTC testnet:
'wallet:bitcoin-bip44-testnet': { format: 'bip44', coinType: 1 },
'wallet:bitcoin-bip49-testnet': { format: 'bip49', coinType: 1 },
// DASH:
'wallet:dash-bip44': { format: 'bip44', coinType: 5 },
// DOGE:
'wallet:dogecoin-bip44': { format: 'bip44', coinType: 3 },
// LTC:
'wallet:litecoin-bip44': { format: 'bip44', coinType: 2 },
'wallet:litecoin-bip49': { format: 'bip49', coinType: 2 },
// FTC:
'wallet:feathercoin-bip49': { format: 'bip49', coinType: 8 },
'wallet:feathercoin-bip44': { format: 'bip44', coinType: 8 },
// QTUM:
'wallet:qtum-bip44': { format: 'bip44', coinType: 2301 },
// UFO:
'wallet:ufo-bip49': { format: 'bip49', coinType: 202 },
'wallet:ufo-bip84': { format: 'bip84', coinType: 202 },
// XZC:
'wallet:zcoin-bip44': { format: 'bip44', coinType: 136 },
// The plugin itself could handle these lines, but they are here
// as a historical accident. Please don't add more:
'wallet:bitcoin-testnet': { format: 'bip32' },
'wallet:bitcoin': { format: 'bip32' },
'wallet:bitcoincash-testnet': { format: 'bip32' },
'wallet:litecoin': { format: 'bip32', coinType: 2 },
'wallet:zcoin': { format: 'bip32', coinType: 136 }
}
if (defaults[type] != null) {
return {
id,
keys: { ...defaults[type], ...keys },
type: type.replace(/-bip[0-9]+/, '')
}
}
return walletInfo
}
export async function makeCurrencyWalletKeys(
ai,
walletType,
opts
) {
const { importText, keyOptions, keys } = opts
// Helper function to bundle up the keys:
function finalizeKeys(newKeys, imported) {
if (imported != null) newKeys = { ...newKeys, imported }
return fixWalletInfo(
makeKeyInfo(walletType, {
...wasEdgeStorageKeys(createStorageKeys(ai)),
...newKeys
})
)
}
// If we have raw keys, just return those:
if (keys != null) return finalizeKeys(keys)
// Grab the currency tools:
const pluginId = findCurrencyPluginId(
ai.props.state.plugins.currency,
walletType
)
const tools = await getCurrencyTools(ai, pluginId)
// If we have text to import, use that:
if (importText != null) {
if (tools.importPrivateKey == null) {
throw new Error('This wallet does not support importing keys')
}
return finalizeKeys(
await tools.importPrivateKey(importText, keyOptions),
true
)
}
// Derive fresh keys:
return finalizeKeys(
await tools.createPrivateKey(walletType, keyOptions),
false
)
}
export async function finishWalletCreation(
ai,
accountId,
walletId,
opts
) {
const { enabledTokenIds, fiatCurrencyCode, migratedFromWalletId, name } = opts
const wallet = await waitForCurrencyWallet(ai, walletId)
// Write ancillary files to disk:
if (migratedFromWalletId != null) {
await changeWalletStates(ai, accountId, {
[walletId]: { migratedFromWalletId }
})
}
if (name != null) {
await wallet.renameWallet(name)
}
if (fiatCurrencyCode != null) {
await wallet.setFiatCurrencyCode(fiatCurrencyCode)
}
if (enabledTokenIds != null && enabledTokenIds.length > 0) {
await wallet.changeEnabledTokenIds(enabledTokenIds)
}
return wallet
}
function getLast(array) {
return array[array.length - 1]
}
/**
* Returns the earliest date, or undefined if neither date exists.
*/
function mergeKeyDate(
a,
b
) {
if (a == null) return b
if (b == null) return a
return new Date(Math.min(a.valueOf(), b.valueOf()))
}