packages/fxa-auth-server/scripts/recorded-future/lib.ts (179 lines of code) (raw):

/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import createClient from 'openapi-fetch'; import { components, paths } from './identity-schema'; import { DB } from '../../lib/db'; import { ERRNO } from '../../lib/error'; import * as pbkdf2 from '../../lib/crypto/pbkdf2'; import PasswordFn from '../../lib/crypto/password'; import hkdf from '../../lib/crypto/hkdf'; import { parseSalt, V1_PBKDF2_ITERATIONS, V2_PBKDF2_ITERATIONS, } from '../../lib/routes/utils/client-key-stretch'; // the generated schema is failing us a bit so here we define the type of the // objects in 'identities' of the search results export type SearchResultIdentity = Required< Pick<components['schemas']['DomainLogin'], 'login' | 'domain'> >; export const defaultPerPageLimit = 500; // the Recorded Future Identity OpenAPI definition does not include any // response information aside from the http 200 response. it appears that the // error can be a string or an object with a 'message' string property (but // it's not an Error instance) export const createRecordedFutureRespError = (error) => new Error( `Recorded Future credentials search error: ${(error as any).message ?? error}`, { cause: error } ); export const createCredentialsSearchFn = (client: ReturnType<typeof createClient<paths>>) => async (payload: components['schemas']['CredentialsSearchRequest']) => { const { error, data } = await client.POST('/identity/credentials/search', { body: payload, }); if (error) { throw createRecordedFutureRespError(error); } return data; }; export const fetchAllCredentialSearchResults = async ( searchFn: ( payload: components['schemas']['CredentialsSearchRequest'] ) => | Promise<components['schemas']['SearchResponse']> | Promise<Promise<components['schemas']['SearchResponse']>>, payload: components['schemas']['CredentialsSearchRequest'] ) => { let credentials: components['schemas']['SearchResponseIdentity'][] = []; let res: components['schemas']['SearchResponse'] | undefined; const searchPayload = { ...payload, limit: payload.limit ?? defaultPerPageLimit, }; do { const reqPayload = { ...searchPayload, ...(res?.next_offset ? { offset: res.next_offset } : {}), }; res = await searchFn(reqPayload); credentials = credentials.concat(res.identities ?? []); } while (res?.next_offset && res.count === searchPayload.limit); return credentials as unknown as SearchResultIdentity[]; }; export const createFindAccountFn = (accountFn: DB['accountRecord']) => async (email: string) => { try { const acct = await accountFn(email); return acct; } catch (err) { if (err.errno !== ERRNO.ACCOUNT_UNKNOWN) { throw err; } } return; }; export const createHasTotp2faFn = (totpTokenFn: DB['totpToken']) => async ( account: NonNullable< Awaited<ReturnType<ReturnType<typeof createFindAccountFn>>> > ) => { try { await totpTokenFn(account.uid); return true; } catch (err) { if (err.errno !== ERRNO.TOTP_TOKEN_NOT_FOUND) { throw err; } } return false; }; export const createCredentialsLookupFn = (client: ReturnType<typeof createClient<paths>>) => async ( logins: SearchResultIdentity[], filter: components['schemas']['CredentialsLookupRequest']['filter'] ) => { /** * Note that there is an inconsistency/discrepancy between the API and the * docs. When a list of `subjects_login` is passed, all the results will * be returned, even when there are over 500 items in the list. * * Since the input size is based on the results of the search results, it's * unknown, so we'll chop up the list ourselves. */ // each identity could have >1 credentials. collect into an array of // credentials and filter for cleartext secret const credentialsWithCleartextSecret: components['schemas']['Credentials'][] = []; for (let i = 0; i < logins.length; i += defaultPerPageLimit) { const subjectLogins = logins.slice(i, i + defaultPerPageLimit); const { error, data } = await client.POST( '/identity/credentials/lookup', { body: { subjects_login: subjectLogins, filter, }, } ); if (error) { throw createRecordedFutureRespError(error); } for (const identity of data.identities || []) { const cleartextSecretCreds = identity.credentials?.filter( (id) => id.exposed_secret?.type === 'clear' ); // the same combination of login and password could show up due to // different leak sources const creds = new Map(); cleartextSecretCreds?.forEach((x) => creds.set( `${x.subject}${x.exposed_secret?.details?.clear_text_value}`, x ) ); credentialsWithCleartextSecret.push(...creds.values()); } } return credentialsWithCleartextSecret; }; export const getCredentials = async ( account: Awaited<ReturnType<ReturnType<typeof createFindAccountFn>>>, password: string ) => { // if key stretching v2 values are present then we just do that const { iterations, salt } = (() => { const saltInfo = account?.clientSalt ? parseSalt(account?.clientSalt) : null; if ( account?.verifyHashVersion2 && account?.wrapWrapKbVersion2 && saltInfo?.version === 2 ) { return { iterations: V2_PBKDF2_ITERATIONS, salt: saltInfo.value }; } return { iterations: V1_PBKDF2_ITERATIONS, salt: account?.normalizedEmail, }; })(); const stretch = await pbkdf2.derive( Buffer.from(password), hkdf.KWE('quickStretch', salt), iterations, 32 ); const authPW = await hkdf(stretch, 'authPW', null, 32); const unwrapBKey = await hkdf(stretch, 'unwrapBKey', null, 32); return { authPW, unwrapBKey }; }; export const createVerifyPasswordFn = ( Password: ReturnType<typeof PasswordFn>, checkPasswordFn: DB['checkPassword'], getCredentialsFn: typeof getCredentials = getCredentials // param is mainly for testing ) => async ( foundCredentials: components['schemas']['Credentials'], account: Awaited<ReturnType<ReturnType<typeof createFindAccountFn>>> ) => { const fxaCredentials = await getCredentialsFn( account, foundCredentials.exposed_secret?.details?.clear_text_value as string ); const password = new Password( fxaCredentials.authPW, account?.authSalt, account?.verifierVersion ); const verifyHash = await password.verifyHash(); const passwordCheck = await checkPasswordFn( account?.uid as string, verifyHash ); return passwordCheck.match; };