UNPKG

edge-core-js

Version:

Edge account & wallet management library

589 lines (526 loc) 15.4 kB
function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }/** * Functions for working with login data in its on-disk format. */ import { asBoolean } from 'cleaners' import { base64 } from 'rfc4648' import { asLoginPayload } from '../../types/server-cleaners' import { asMaybeOtpError, } from '../../types/types' import { decrypt, decryptText } from '../../util/crypto/crypto' import { totp } from '../../util/crypto/hotp' import { verifyData } from '../../util/crypto/verify' import { softCat } from '../../util/util' import { loginFetch } from './login-fetch' import { makeSecretKit } from './login-secret' import { getChildStash, getStashById } from './login-selectors' import { saveStash } from './login-stash' import { getStashOtp } from './otp' /** * Returns the login that satisfies the given predicate, * or undefined if nothing matches. */ export function searchTree( node, predicate ) { if (predicate(node)) return node const flowHack = node if (flowHack.children != null) { for (const child of flowHack.children) { const out = searchTree(child, predicate) if (out != null) return out } } } /** * Walks a tree, building a new tree. * The `predicate` callback returns true when we reach the node to replace, * and the `update` callback replaces that node. * The `clone` callback updates the `children` on the non-replaced nodes. */ function updateTree( node, predicate, update, clone ) { if (predicate(node)) return update(node) const children = node.children != null ? node.children.map(child => updateTree(child, predicate, update, clone)) : [] return clone(node, children) } function applyLoginPayloadInner( stash, loginKey, loginReply ) { const { children: stashChildren = [] } = stash const { appId, created, loginId, loginAuthBox, userId, otpKey, otpResetDate, otpTimeout, pendingVouchers, parentBox, passwordAuthBox, passwordAuthSnrp, passwordBox, passwordKeySnrp, pin2TextBox, children = [], keyBoxes = [], mnemonicBox, rootKeyBox, syncKeyBox, syncToken } = loginReply const out = { appId, created, loginId, loginAuthBox, userId, otpKey: otpKey === true ? stash.otpKey : otpKey, otpResetDate, otpTimeout, pendingVouchers, parentBox, passwordAuthBox, passwordAuthSnrp, passwordBox: passwordBox === true ? stash.passwordBox : passwordBox, passwordKeySnrp, pin2TextBox, keyBoxes, // We should be more picky about these mnemonicBox, rootKeyBox, syncKeyBox, syncToken } // Preserve client-only data: if (stash.lastLogin != null) out.lastLogin = stash.lastLogin if (stash.username != null) out.username = stash.username if (stash.userId != null && out.userId == null) out.userId = stash.userId // Store the pin key unencrypted: if (loginReply.pin2KeyBox != null) { out.pin2Key = decrypt(loginReply.pin2KeyBox, loginKey) } // Store the recovery key unencrypted: if (loginReply.recovery2KeyBox != null) { out.recovery2Key = decrypt(loginReply.recovery2KeyBox, loginKey) } // Sort children oldest to newest: children.sort((a, b) => a.created.valueOf() - b.created.valueOf()) // Recurse into children: out.children = children.map(child => { const { appId, loginId, parentBox } = child // Read the decryption key: if (parentBox == null) { throw new Error('Key integrity violation: No parentBox on child login.') } const childKey = decrypt(parentBox, loginKey) // Find a stash to merge with: const existingChild = stashChildren.find(child => verifyData(child.loginId, loginId) ) const childStash = _nullishCoalesce(existingChild, () => ( { appId, loginId, pendingVouchers: [] })) return applyLoginPayloadInner(childStash, childKey, child) }) // Check for missing children: for (const { loginId } of stashChildren) { const replyChild = children.find(child => verifyData(child.loginId, loginId) ) if (replyChild == null) { throw new Error('The server has lost children!') } } return out } /** * Updates the given login stash object with fields from the auth server. * TODO: We don't trust the auth server 100%, so be picky about what we copy. */ export function applyLoginPayload( stashTree, loginKey, loginReply ) { return updateTree( stashTree, stash => stash.appId === loginReply.appId, stash => applyLoginPayloadInner(stash, loginKey, loginReply), (stash, children) => ({ ...stash, children }) ) } function makeLoginTreeInner( stash, loginKey ) { const { appId, created, lastLogin = new Date(), loginId, otpKey, otpResetDate, otpTimeout, pendingVouchers, userId, username, children: stashChildren = [] } = stash const login = { appId, created, isRoot: stash.parentBox == null, lastLogin, loginId, loginKey, otpKey, otpResetDate, otpTimeout, pendingVouchers, userId, username, children: [] } // Server authentication: if (stash.loginAuthBox != null) { login.loginAuth = decrypt(stash.loginAuthBox, loginKey) } if (stash.passwordAuthBox != null) { if (login.userId == null) login.userId = loginId login.passwordAuth = decrypt(stash.passwordAuthBox, loginKey) } if (login.loginAuth == null && login.passwordAuth == null) { throw new Error('No server authentication methods on login') } // PIN v2: login.pin2Key = stash.pin2Key if (stash.pin2TextBox != null) { login.pin = decryptText(stash.pin2TextBox, loginKey) } // Recovery v2: login.recovery2Key = stash.recovery2Key // Recurse into children: login.children = stashChildren.map(child => { if (child.parentBox == null) { throw new Error('Key integrity violation: No parentBox on child login.') } const childKey = decrypt(child.parentBox, loginKey) return makeLoginTreeInner(child, childKey) }) return login } /** * Converts a login stash into an in-memory login object. */ export function makeLoginTree( stashTree, sessionKey ) { return updateTree( stashTree, stash => verifyData(stash.loginId, sessionKey.loginId), stash => makeLoginTreeInner(stash, sessionKey.loginKey), (stash, children) => { const { appId, lastLogin = new Date(), loginId, otpKey, pendingVouchers, username } = stash // Hack: The types say this must be present, // but we don't actually have a root key for child logins. // This affects everybody, so fixing it will be quite hard: const loginKey = undefined return { appId, children, isRoot: stash.parentBox == null, lastLogin, loginId, loginKey, otpKey, pendingVouchers, username } } ) } /** * Prepares a login stash for edge login, * stripping out any information that the target app is not allowed to see. */ export function sanitizeLoginStash( stashTree, appId ) { return updateTree( stashTree, stash => stash.appId === appId, stash => stash, (stash, children) => { const { appId, loginId, username } = stash return { appId, children, loginId, pendingVouchers: [], username } } ) } /** * Logs a user in, using the auth server to retrieve information. * The various login methods (password / PIN / recovery, etc.) share * common logic, which all lives in here. * * The things tha differ between the methods are the server payloads * and the decryption steps, so this function accepts those two things * as parameters, plus the ordinary login options. */ export async function serverLogin( ai, stashTree, stash, opts, serverAuth, decrypt ) { const { now = new Date() } = opts const { deviceDescription } = ai.props.state.login const request = { challengeId: opts.challengeId, otp: getStashOtp(stash, opts), voucherId: stash.voucherId, voucherAuth: stash.voucherAuth, ...serverAuth } if (deviceDescription != null) request.deviceDescription = deviceDescription let loginReply = asLoginPayload( await loginFetch(ai, 'POST', '/v2/login', request).catch( (error) => { // Save the username / voucher if we get an OTP error: const otpError = asMaybeOtpError(error) if ( otpError != null && // We have never seen this user before: ((stash.loginId.length === 0 && otpError.loginId != null) || // We got a voucher: (otpError.voucherId != null && otpError.voucherAuth != null)) ) { if (otpError.loginId != null) { stash.loginId = base64.parse(otpError.loginId) } if (otpError.voucherAuth != null) { stash.voucherId = otpError.voucherId stash.voucherAuth = base64.parse(otpError.voucherAuth) } stashTree.lastLogin = now saveStash(ai, stashTree).catch(() => {}) } throw error } ) ) // Try decrypting the reply: const { loginId } = loginReply const loginKey = await decrypt(loginReply) // Save the latest data: stashTree = applyLoginPayload(stashTree, loginKey, loginReply) stashTree.lastLogin = now await saveStash(ai, stashTree) // Ensure the account has secret-key login enabled: if (loginReply.loginAuthBox == null) { const { stash, stashTree } = getStashById(ai, loginId) const secretKit = makeSecretKit(ai, { loginId, loginKey }) const request = { ...serverAuth, otp: getStashOtp(stash, opts), data: secretKit.server } loginReply = asLoginPayload( await loginFetch(ai, 'POST', secretKit.serverPath, request) ) await saveStash(ai, applyLoginPayload(stashTree, loginKey, loginReply)) } return { loginId, loginKey } } /** * Changing a login involves updating the server, the in-memory login, * and the on-disk stash. A login kit contains all three elements, * and this function knows how to apply them all. */ export async function applyKit( ai, sessionKey, kit ) { const { serverMethod = 'POST', serverPath } = kit const { stashTree } = getStashById(ai, kit.loginId) const { deviceDescription } = ai.props.state.login // Don't make server-side changes if the server path is faked: if (serverPath !== '') { const childKey = decryptChildKey(stashTree, sessionKey, kit.loginId) const request = makeAuthJson(stashTree, childKey) if (deviceDescription != null) request.deviceDescription = deviceDescription request.data = kit.server await loginFetch(ai, serverMethod, serverPath, request) } const newStashTree = updateTree( stashTree, stash => verifyData(stash.loginId, kit.loginId), stash => ({ ...stash, ...kit.stash, children: softCat(stash.children, kit.stash.children), keyBoxes: softCat(stash.keyBoxes, kit.stash.keyBoxes) }), (stash, children) => ({ ...stash, children }) ) await saveStash(ai, newStashTree) return newStashTree } /** * Applies an array of kits to a login, one after another. * We can't use `Promise.all`, since `applyKit` doesn't handle * parallelism correctly. Also, we want to stop if there are errors * (such as failing to change the root username). */ export async function applyKits( ai, sessionKey, kits ) { for (const kit of kits) { if (kit == null) continue await applyKit(ai, sessionKey, kit) } } /** * Refreshes a login with data from the server. */ export async function syncLogin( ai, sessionKey ) { const { stashTree, stash } = getStashById(ai, sessionKey.loginId) // First, hit the fast endpoint to see if we even need to sync: const { syncToken } = stash if (syncToken != null) { try { const reply = await loginFetch(ai, 'POST', '/v2/sync', { loginId: stash.loginId, syncToken }) if (asBoolean(reply)) return } catch (error) { // We can fall back on a full sync if we fail here. } } // If we do need to sync, prepare for a full login: const request = makeAuthJson(stashTree, sessionKey) const opts = { // Avoid updating the lastLogin date: now: stashTree.lastLogin } await serverLogin(ai, stashTree, stash, opts, request, async () => { return sessionKey.loginKey }) } /** * Finds the session key for a child login. */ export function decryptChildKey( stashTree, sessionKey, loginId ) { function searchChildren( childList, loginKey ) { for (const child of childList) { // This will never happen, but TypeScript doesn't know that: if (child.parentBox == null) continue // If this is the right one, return it: if (verifyData(child.loginId, loginId)) { return { loginId: child.loginId, loginKey: decrypt(child.parentBox, loginKey) } } // We can skip the next decryption if there are no children: const { children = [] } = child if (children.length === 0) continue // Otherwise, we need to decrypt the child's key, and recurse in: const out = searchChildren(children, decrypt(child.parentBox, loginKey)) if (out != null) return out } } // If this is already the right session key, do nothing: if (verifyData(loginId, sessionKey.loginId)) return sessionKey // Find the stash this key goes with: const stash = getChildStash(stashTree, sessionKey.loginId) // Recurse into its children: const out = searchChildren(_nullishCoalesce(_optionalChain([stash, 'optionalAccess', _ => _.children]), () => ( [])), sessionKey.loginKey) if (out == null) { throw new Error( `Cannot decrypt child login '${base64.stringify(sessionKey.loginId)}'` ) } return out } /** * Sets up a login v2 server authorization JSON. */ export function makeAuthJson( stashTree, sessionKey ) { const stash = getChildStash(stashTree, sessionKey.loginId) const { loginAuthBox, otpKey, passwordAuthBox, syncToken, userId, voucherAuth, voucherId } = stash const otp = otpKey != null ? totp(otpKey) : undefined if (loginAuthBox != null) { return { loginAuth: decrypt(loginAuthBox, sessionKey.loginKey), loginId: sessionKey.loginId, otp, syncToken, voucherAuth, voucherId } } if (passwordAuthBox != null && userId != null) { return { passwordAuth: decrypt(passwordAuthBox, sessionKey.loginKey), userId, otp, syncToken, voucherAuth, voucherId } } throw new Error('No server authentication methods available') }