packages/fxa-auth-server/scripts/recorded-future/check-and-reset.ts (313 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/. */
/**
* A script to check Recorded Future's Identity API for leaked credential, and
* force an account reset on accounts with valid leaked credentials.
*
* This script relies on the same set of environment variables as the FxA auth
* server.
*/
/**
* The general steps are:
* 1. Query Recorded Future's Identity credential search API endpoint to
* fetch new leaked credential since the previous check.
* 2. For logins that are email addresses with an account in FxA, query
* credential lookup API endpoint to get the leaked credentials.
* 3. For accounts with valid leaked plaintext passwords and no TOTP 2FA,
* reset the account.
* 4. Send an email to the affected account holder to reset their password.
*/
import crypto from 'crypto';
import { promisify } from 'util';
import { Command } from 'commander';
import { Container } from 'typedi';
import { StatsD } from 'hot-shots';
import createClient from 'openapi-fetch';
import appConfig from '../../config';
import logFn from '../../lib/log';
import { createDB } from '../../lib/db';
import oauthDb from '../../lib/oauth/db';
import * as butil from '../../lib/crypto/butil';
import PasswordFn from '../../lib/crypto/password';
import bouncesFn from '../../lib/bounces';
import sendersFn from '../../lib/senders';
import TokenFn from '../../lib/tokens';
import { emitStatsdMetrics } from '../lib/metrics';
import { collect, parseBooleanArg } from '../lib/args';
import { paths } from './identity-schema';
import {
createCredentialsLookupFn,
createCredentialsSearchFn,
createFindAccountFn,
createHasTotp2faFn,
createVerifyPasswordFn,
defaultPerPageLimit,
fetchAllCredentialSearchResults,
SearchResultIdentity,
} from './lib';
import { AppConfig } from '../../lib/types';
import { AccountEventsManager } from '../../lib/account-events';
type ResetableAccount = NonNullable<
Awaited<ReturnType<ReturnType<typeof createFindAccountFn>>>
>;
const config = appConfig.getProperties();
const log = logFn({ name: 'recorded-future' });
const Token = TokenFn(log, config);
const Password = PasswordFn(log, config);
const DB = createDB(config, log, Token);
const resultsPerPageLimit = defaultPerPageLimit;
const statsd = new StatsD({ ...config.statsd });
const client = createClient<paths>({
baseUrl: 'https://api.recordedfuture.com',
headers: { 'X-RFToken': config.recordedFuture.identityApiKey },
});
Container.set(AppConfig, config);
Container.set(StatsD, statsd);
let authDb: Awaited<ReturnType<typeof DB.connect>> | null;
const checkAndReset = async () => {
const program = new Command();
const searchDomains = collect();
const accountEmails = collect();
program
.description(
`Query Recorded Future's Identity API for leaked account credentials. Accounts
with valid leaked credentials and without TOTP 2FA will be reset. An email
will be sent to the account holder.`
)
.option(
'--first-downloaded-date <date>',
'The date after when the account credential was first downloaded by Recorded Future. If not given, the date in UTC from 24 hours ago will be used.',
Date.parse
)
.option(
'--search-domain [string]',
'The domains to search in Recorded Future. Repeatable. Defaults to accounts.firefox.com.',
searchDomains,
['accounts.firefox.com']
)
.option(
'--email [string]',
'Email address of an account to reset. Repeatable.',
accountEmails,
[]
)
.option(
'--dry-run [true|false]',
'Print out the argument and configuration values that will be used in the execution of the script. Defaults to true.',
parseBooleanArg,
true
);
program.parse(process.argv);
const resetWithEmailArgs = program.email.length > 0;
if (resetWithEmailArgs) {
console.log(
'\nEmail arguments found. ALL Recorded Future arguments will be ignored.'
);
}
const domains = [...new Set(program.searchDomain as unknown as string[])];
const firstDownloadedDatetime =
program.firstDownloadedDate && !Number.isNaN(program.firstDownloadedDate)
? new Date(program.firstDownloadedDate)
: new Date(Date.now() - 24 * 60 * 60 * 1000);
const firstDownloadedDateIsoString = firstDownloadedDatetime
.toISOString()
.split('T')[0];
const accountsToReset = await (async () => {
if (!resetWithEmailArgs) {
return await findLeakedAccounts(firstDownloadedDateIsoString, domains);
}
const loginsFromEmailArgs = program.email.map((x) => ({
login: x,
}));
const { accounts } = await getAccountsByLogin(loginsFromEmailArgs);
return Array.from(accounts.values());
})();
if (program.dryRun) {
console.log(`
Dry run mode is on. It is the default; use '--dry-run false' when you are ready.
Domains: ${domains.join(', ')}
Filter: first_downloaded_date_gte=${firstDownloadedDateIsoString}
Limit: ${resultsPerPageLimit}
Account emails to reset:
${accountsToReset.map((x) => `${`\t`}${x.email}`).join('\n')}
`);
return 0;
}
if (accountsToReset.length === 0) {
log.info('recordedFuture.info', { message: 'No eligible accounts found.' });
return 0;
}
await resetAccounts(accountsToReset, resetWithEmailArgs);
return 0;
};
async function findLeakedAccounts(
firstDownloadedDate: string,
domains: string[]
) {
const accountsToReset: ResetableAccount[] = [];
const usernamePropertiesFilter = ['Email'] as 'Email'[];
const payload = {
domains,
filter: {
first_downloaded_gte: firstDownloadedDate,
username_properties: usernamePropertiesFilter,
},
limit: resultsPerPageLimit,
};
const _credentialsSearchFn = createCredentialsSearchFn(client);
const credentialsSearch = emitStatsdMetrics(
_credentialsSearchFn,
'recorded-future.identity.credentials-search',
statsd
);
const leakedLogins = await fetchAllCredentialSearchResults(
credentialsSearch,
payload
);
if (leakedLogins.length === 0) {
return [];
}
statsd.increment(
'recorded-future.identity.credentials-search.results.total',
leakedLogins.length
);
const { accounts, has2faCount } = await getAccountsByLogin(leakedLogins);
if (accounts.size) {
statsd.increment(
'recorded-future.identity.credentials-search.results.account-emails',
accounts.size
);
}
if (has2faCount) {
statsd.increment(
'recorded-future.identity.credentials-search.results.2fa-accounts',
has2faCount
);
}
if (accounts.size === 0) {
return [];
}
const accountsByLogin: Record<
SearchResultIdentity['login'],
ResetableAccount
> = Array.from(accounts.entries()).reduce((acc, val) => {
acc[val[0].login] = val[1];
return acc;
}, {});
const credentialsLookupFn = createCredentialsLookupFn(client);
const cleartextCredentials = await credentialsLookupFn(
Array.from(accounts.keys()),
{
first_downloaded_gte: firstDownloadedDate,
}
);
statsd.increment(
'recorded-future.identity.credentials-lookup.cleartext-total',
cleartextCredentials.length
);
const db = await getAuthDb();
const verifyPassword = createVerifyPasswordFn(
Password,
db.checkPassword.bind(db)
);
for (const foundCredentials of cleartextCredentials) {
const acct = accountsByLogin[foundCredentials.subject as string];
const passwordMatched = await verifyPassword(foundCredentials, acct);
if (passwordMatched) {
accountsToReset.push(acct);
statsd.increment(
'recorded-future.identity.credentials-lookup.password-match'
);
}
}
return accountsToReset;
}
async function getAccountsByLogin(logins: SearchResultIdentity[]) {
const findAcct = await getFindAccountFn();
const hasTotp2FA = await getHasTotp2faFn();
const accounts: Map<SearchResultIdentity, ResetableAccount> = new Map();
let has2faCount = 0;
for (const x of logins) {
const acct = await findAcct(x.login);
if (acct) {
const has2FA = await hasTotp2FA(acct);
if (!has2FA) {
accounts.set(x, acct);
} else {
has2faCount++;
}
}
}
return { accounts, has2faCount };
}
async function resetAccounts(
accountsToReset: ResetableAccount[],
resetWithEmails: boolean
) {
const authDb = await getAuthDb();
const bounces = bouncesFn(config, authDb);
const senders = await sendersFn(log, config, bounces, statsd);
// the dynamically named `send\w+Email` functions are not in the type of senders.email
const mailer: any = senders.email;
const accountEventManager = new AccountEventsManager();
for (const acct of accountsToReset) {
try {
await authDb.resetAccount(
{ uid: acct.uid },
{
authSalt: butil.ONES.toString('hex'),
verifyHash: butil.ONES.toString('hex'),
wrapWrapKb: crypto.randomBytes(32).toString('hex'),
verifyHashVersion2: butil.ONES.toString('hex'),
wrapWrapKbVersion2: crypto.randomBytes(32).toString('hex'),
verifierVersion: 1,
}
);
await oauthDb.removeTokensAndCodes(acct.uid);
await mailer.sendPasswordChangeRequiredEmail(acct.emails, acct);
await accountEventManager.recordSecurityEvent(authDb, {
uid: acct.uid,
name: 'account.must_reset',
// loopback addr since this is not a user action
ipAddr: '127.0.0.1',
});
if (!resetWithEmails) {
statsd.increment(
'recorded-future.identity.credentials-lookup.account-reset'
);
} else {
statsd.increment('recorded-future.email-direct.account-reset');
}
log.info('account.forceReset', {
uid: acct.uid,
recordedFuture: !resetWithEmails,
});
} catch (err) {
log.error('account.failedReset', {
uid: acct.uid,
recordedFuture: !resetWithEmails,
error: err,
});
}
}
}
async function getAuthDb() {
if (authDb) {
return authDb;
}
authDb = await DB.connect(config, undefined);
return authDb;
}
async function getFindAccountFn() {
const db = await getAuthDb();
const getAccountRecord = db.accountRecord.bind(db);
return createFindAccountFn(getAccountRecord);
}
async function getHasTotp2faFn() {
const db = await getAuthDb();
const getTotpToken = db.totpToken.bind(db);
return createHasTotp2faFn(getTotpToken);
}
if (require.main === module) {
checkAndReset()
.then(
(exitCode) => {
statsd.increment('recorded-future.identity.script.success', {
exitCode: `${exitCode}`,
});
return exitCode;
},
(err) => {
log.error('recordedFuture.error', { error: err });
statsd.increment('recorded-future.identity.script.error', {
exitCode: '1',
});
return 1;
}
)
.then((exitCode) => {
return promisify(statsd.close)
.bind(statsd)()
.then(() => exitCode);
})
.then((exitCode: number) => process.exit(exitCode));
}