diff --git a/README.md b/README.md index 3e00884..a223c41 100644 --- a/README.md +++ b/README.md @@ -1456,8 +1456,8 @@ do. ```sh ftb secrets # what each return can prove, best first ftb register business --email you@example.com --dry-run -ftb register business --email you@example.com --declare -ftb register personal --email you@example.com --declare +ftb register business --email you@example.com --phone 5555550100 --declare +ftb register personal --email you@example.com --phone 5555550100 --call --declare ftb activate business --pin 4821 # when the letter comes ftb status ``` @@ -1476,13 +1476,24 @@ ftb status `--declare`, which is you saying the values are true. One shared secret is sent per run and never retried: a "does not match" stops, and `ftb secrets` lists what to try with `--year`/`--line`. +- **Throttled, so a lockout cannot snowball.** FTB restarts its 30-minute + lock on every attempt made inside it. `ftb` keeps a ledger + (`~/.local/share/cli-tools/ftb-throttle.json`): nothing runs during a + recorded lockout (35 minutes, `--force` included), at most 2 attempts per + account per 30 minutes and 4 a day, 2 minutes between any two runs. One + Chrome profile is kept between runs so a browser FTB's bot check already + trusts is not challenged on login. +- **Phone codes.** `--phone` takes the number FTB texts; `--call` has it read + out instead. The run waits 15 minutes for the code, typed or written to + `~/.local/share/cli-tools/ftb-code-`, and a wrong code waits for the + next one. - **Where the login goes.** A generated user name, a 24-character password and three security answers go to the `ftb` project of the `profullstack` logicsrc vault (its own project, so the push cannot drop another key) and to a 0600 state file. -- **The PIN letter.** A myna hand-off card (`mynaposter.com/handoff/`) with - the deadline (21 days) and the `ftb activate` command. It carries steps only, - never a PIN, password or SSN; `ftb activate` marks it done. +- **The PIN letter.** FTB mails a PIN that expires in 21 days; `ftb status` + shows each account's deadline and `ftb activate --pin N` finishes it. + Nothing about these accounts is posted to any other service. Needs `python3` with PyMuPDF (`pip install pymupdf`) and Chrome. diff --git a/bin/ftb.ts b/bin/ftb.ts index f20d092..750f985 100755 --- a/bin/ftb.ts +++ b/bin/ftb.ts @@ -32,13 +32,19 @@ import { ROLES, type Role, type State, + type Throttle, VAULT, + checkThrottle, + emptyThrottle, + isLockout, + recordAttempt, + recordLockout, addressNumbers, businessSecrets, formatAmount, generatePassword, generateUsername, - handoffCard, + activationDeadline, latestIdentity, maskSsn, parseExtracted, @@ -68,6 +74,9 @@ register options: --year YYYY use this tax year's return instead of the newest --line 15|20 business: which 100S line is the net income (default 20) --amount=N override the amount (a loss is --amount=-12345) + --phone N 10 digits FTB texts a verification code to (required) + --call have FTB phone the code in instead of texting it + --force skip the 2-per-30-minutes / 4-a-day / 2-minute spacing limits (never a lockout) activate options: --pin N the PIN from the letter (21 days from registration) @@ -76,13 +85,12 @@ Common: --dir D where the returns are (default: ${DEFAULT_DIR}) --headful show the browser (needs a display) --chrome PATH the browser to use (default: CHROME_PATH, then the usual places) - --no-handoff do not post the PIN-letter card to myna --no-vault keep the login only in ${statePath()} One shared secret is sent per run. When FTB says it does not match, the run stops and \`ftb secrets\` lists what else the returns support; nothing is retried on its own. Logins go to the ${VAULT.project} vault (${VAULT.team}) and a 0600 -state file; the myna card carries steps only, never a PIN, password or SSN. +state file. Nothing about these accounts is posted to any other service. `; function out(text: string): void { @@ -122,6 +130,40 @@ function saveState(state: State, path = statePath()): void { chmodSync(path, 0o600); } +function throttlePath(): string { + return join(dirname(statePath()), 'ftb-throttle.json'); +} + +function loadThrottle(): Throttle { + try { + return JSON.parse(readFileSync(throttlePath(), 'utf8')) as Throttle; + } catch { + return emptyThrottle(); + } +} + +function saveThrottle(throttle: Throttle): void { + mkdirSync(dirname(throttlePath()), { recursive: true, mode: 0o700 }); + writeFileSync(throttlePath(), `${JSON.stringify(throttle, null, 2)}\n`, { mode: 0o600 }); +} + +/** Refuse to start when FTB would count this run against a lock or a limit; otherwise log it as an attempt. */ +function gate(role: Role, kind: string, force: boolean): void { + const now = new Date(); + const throttle = loadThrottle(); + const verdict = checkThrottle(throttle, role, now, force); + if (!verdict.ok) { + const at = verdict.until.toLocaleTimeString(); + throw new FtbError(`not now: ${verdict.reason}. Next attempt at ${at}${verdict.reason.includes('locked') ? '' : ' (or --force)'}.`); + } + saveThrottle(recordAttempt(throttle, role, kind, now)); +} + +/** After a run: a lockout page starts the 35-minute block on that account. */ +function noteResult(role: Role, text: string): void { + if (isLockout(text)) saveThrottle(recordLockout(loadThrottle(), role, new Date())); +} + /** Pull the ftb vault, merge this account in, push it back. The plaintext lives for one call in a 0700 dir. */ function pushVault(account: Account): void { let current: Record = {}; @@ -141,13 +183,11 @@ function pushVault(account: Account): void { } } -/** Post the PIN-letter card to myna; returns its URL, or null when myna is not there. */ -function postHandoff(role: Role, at: Date): string | null { - const card = handoffCard(role, at); - const args = ['handoff', 'add', 'ftb', '--title', card.title, '--open', LOGIN_URL, ...card.steps.flatMap((step) => ['--step', step])]; - const result = spawnSync('myna', args, { input: card.text, encoding: 'utf8' }); - if (result.error || result.status !== 0) return null; - return result.stdout.match(/https:\/\/\S+\/handoff\/\S+/)?.[0] ?? result.stdout.trim().split('\n').pop() ?? null; + +/** The message on an FTB result page: its body text between the header and the footer. */ +function pageMessage(text: string): string { + const body = text.replace(/^.*?Franchise Tax Board\s*(e-Services)?/s, '').replace(/Back to top.*$|Copyright ©.*$/s, '').trim(); + return body.slice(0, 600) || 'the information does not match'; } // --------------------------------------------------------------------------- @@ -177,8 +217,8 @@ function chooseSecret(all: T[], year: export async function main(argv: readonly string[]): Promise { const args = parseArgs(argv, { - boolean: ['--help', '-h', '--json', '--declare', '--dry-run', '--headful', '--no-handoff', '--no-vault'], - string: ['--dir', '--email', '--year', '--line', '--amount', '--pin', '--chrome'], + boolean: ['--help', '-h', '--json', '--declare', '--dry-run', '--headful', '--no-vault', '--call', '--force'], + string: ['--dir', '--email', '--year', '--line', '--amount', '--pin', '--chrome', '--phone'], }); const [command, roleArg] = args.positional; if (args.flags.has('--help') || args.flags.has('-h') || !command) { @@ -201,7 +241,7 @@ export async function main(argv: readonly string[]): Promise { for (const role of ROLES) { const a = state.accounts[role]; if (!a) out(`${role.padEnd(9)} not registered`); - else out(`${role.padEnd(9)} ${a.username} registered ${a.registeredAt.slice(0, 10)} ${a.activatedAt ? `active since ${a.activatedAt.slice(0, 10)}` : `waiting on the PIN letter${a.handoff ? ` ${a.handoff}` : ''}`}`); + else out(`${role.padEnd(9)} ${a.username} registered ${a.registeredAt.slice(0, 10)} ${a.activatedAt ? `active since ${a.activatedAt.slice(0, 10)}` : `waiting on the PIN letter, activate by ${activationDeadline(a.registeredAt)}`}`); } return 0; } @@ -212,6 +252,8 @@ export async function main(argv: readonly string[]): Promise { const state = loadState(); const say = (line: string) => err(line); const log = join(dirname(statePath()), 'ftb-pages.jsonl'); + const codeFile = join(dirname(statePath()), `ftb-code-${role}`); + const profile = join(dirname(statePath()), 'ftb-chrome'); const headless = !args.flags.has('--headful'); if (command === 'activate') { @@ -223,15 +265,16 @@ export async function main(argv: readonly string[]): Promise { role, firstName: '', lastName: '', username: account.username, password: account.password, email: account.email, addressNumbers: '', zip: '', year: account.secret.year, amount: account.secret.amount, security: { ...account.security }, declare: false, pin, }; - const session = await openSession({ chrome: args.values.get('--chrome'), headless }); + gate(role, 'activate', args.flags.has('--force')); + const session = await openSession({ chrome: args.values.get('--chrome'), headless, profile }); try { await goto(session, LOGIN_URL); say(`Activating the ${role} account ${account.username}:`); - const result = await walk(session, { plan, rules: rulesFor(plan), dryRun: false, log, interactive: process.stdin.isTTY === true, say }); - if (result.outcome === 'rejected') throw new FtbError(`FTB said: ${result.page.errors.join(' ') || 'no'} (${result.page.url})`); + const result = await walk(session, { plan, rules: rulesFor(plan), dryRun: false, log, codeFile, interactive: process.stdin.isTTY === true, say }); + noteResult(role, `${result.page.title} ${result.page.text}`); + if (result.outcome === 'rejected') throw new FtbError(`FTB said: ${result.page.errors.join(' ') || pageMessage(result.page.text)} (${result.page.url})`); account.activatedAt = new Date().toISOString(); saveState(state); - if (account.handoff) spawnSync('myna', ['handoff', 'done', account.handoff.split('/').pop()!], { encoding: 'utf8' }); out(`${role} MyFTB account ${account.username} is active.`); return 0; } finally { @@ -249,6 +292,8 @@ export async function main(argv: readonly string[]): Promise { const line = args.values.has('--line') ? Number(args.values.get('--line')) : undefined; if (line !== undefined && line !== 15 && line !== 20) throw new UsageError('--line is 15 or 20'); + const phone = (args.values.get('--phone') ?? '').replace(/\D/g, ''); + if (phone.length !== 10) throw new UsageError('--phone is the 10-digit number FTB texts a verification code to'); const rows = extract(dir); const identity = latestIdentity(rows); if (!identity) throw new FtbError(`no Form 540 with a name, SSN and address under ${dir}`); @@ -274,22 +319,27 @@ export async function main(argv: readonly string[]): Promise { filingStatus: 'filingStatus' in secret ? secret.filingStatus : undefined, security: {}, declare: args.flags.has('--declare'), + phone, + call: args.flags.has('--call'), }; say(`Registering a ${role} MyFTB account as ${plan.username} <${email}>`); say(` shared secret: ${secret.year} Form ${secret.form}${'line' in secret ? ` line ${secret.line}` : ` (${plan.filingStatus})`} = ${formatAmount(amount)} from ${secret.source}`); say(` address on file: ${plan.addressNumbers} / ${plan.zip}${plan.corpId ? ` corp ${plan.corpId}` : ` SSN ${maskSsn(plan.ssn!)}`}`); - const session = await openSession({ chrome: args.values.get('--chrome'), headless }); + // A dry run stops before the page that creates anything, so it is not an attempt. + if (!args.flags.has('--dry-run')) gate(role, 'register', args.flags.has('--force')); + const session = await openSession({ chrome: args.values.get('--chrome'), headless, profile }); try { await goto(session, REGISTER_URL); - const result = await walk(session, { plan, rules: rulesFor(plan), dryRun: args.flags.has('--dry-run'), log, interactive: process.stdin.isTTY === true, say }); + const result = await walk(session, { plan, rules: rulesFor(plan), dryRun: args.flags.has('--dry-run'), log, codeFile, interactive: process.stdin.isTTY === true, say }); + noteResult(role, `${result.page.title} ${result.page.text}`); if (result.outcome === 'dry-run') { out('Dry run: stopped before the first Continue that sends anything to FTB.'); return 0; } if (result.outcome === 'rejected') { - throw new FtbError(`FTB said: ${result.page.errors.join(' ') || 'the information does not match'} (${result.page.url})\nNothing was retried. \`ftb secrets\` lists the other returns; pick one with --year/--line.`); + throw new FtbError(`FTB said: ${result.page.errors.join(' ') || pageMessage(result.page.text)} (${result.page.url})\nNothing was retried. \`ftb secrets\` lists the other returns; pick one with --year/--line.`); } const at = new Date(); const account: Account = { @@ -307,14 +357,7 @@ export async function main(argv: readonly string[]): Promise { err(` vault push failed, the login is only in ${statePath()}: ${(error as Error).message}`); } } - if (!args.flags.has('--no-handoff')) { - const url = postHandoff(role, at); - if (url) { - account.handoff = url; - saveState(state); - out(` PIN-letter card: ${url}`); - } - } + out(` PIN letter by mail; activate by ${activationDeadline(account.registeredAt)}.`); out(` When the letter comes: ftb activate ${role} --pin `); return 0; } finally { diff --git a/package.json b/package.json index a5f574b..17955d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/cli-tools", - "version": "0.54.0", + "version": "0.55.0", "private": true, "description": "Local command-line tools, in TypeScript, exposed on PATH.", "type": "module", diff --git a/src/ftb-run.ts b/src/ftb-run.ts index d1bda44..007b932 100644 --- a/src/ftb-run.ts +++ b/src/ftb-run.ts @@ -7,10 +7,10 @@ * HeadlessChrome (the same trick src/statements-run.ts uses on banks). */ -import { appendFileSync, mkdirSync } from 'node:fs'; +import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; import { dirname } from 'node:path'; -import { type Action, type Field, FtbError, type Outcome, type Plan, type Rule, decide, outcomeOf } from './ftb.ts'; +import { type Action, CODE_LABEL, type Field, FtbError, type Outcome, type Plan, type Rule, decide, outcomeOf } from './ftb.ts'; import { promptLine, promptSecret } from './prompt.ts'; import { type Browser, type Cdp, launchBrowser } from './wcag.ts'; @@ -82,14 +82,18 @@ function fillScript(selector: string, action: Action): string { })()`; } -/** The page's own Continue/Submit/Log in, never the session-timeout dialog's "Continue Session". */ +/** + * The page's own forward button, never the session-timeout dialog's "Continue + * Session" nor Back/Cancel. Known labels first, then the page's only other + * submit; when neither fits, the labels seen come back as `?a|b` for the error. + */ const SUBMIT = `(() => { const visible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); - const buttons = [...document.querySelectorAll('form button, form input[type=submit]')] - .filter((b) => visible(b) && !b.closest('#timer, .modal') && b.id !== 'continue'); const text = (b) => (b.innerText || b.value || '').trim(); - const pick = buttons.find((b) => /^(submit|continue|next|log ?in|login|activate)$/i.test(text(b))); - if (!pick) return null; + const buttons = [...document.querySelectorAll('form button, form input[type=submit], form input[type=button]')] + .filter((b) => visible(b) && !b.closest('#timer, .modal') && b.id !== 'continue' && text(b) && !/^(back|cancel|end session|previous)$/i.test(text(b))); + const pick = buttons.find((b) => /^(submit|continue|next|log ?in|login|activate|send( code| me a code)?|verify|confirm)$/i.test(text(b))) || (buttons.length === 1 ? buttons[0] : null); + if (!pick) return '?' + buttons.map(text).join('|'); pick.click(); return text(pick); })()`; @@ -100,8 +104,19 @@ export interface Session { sessionId: string; } -export async function openSession(options: { chrome?: string | undefined; headless: boolean; timeoutMs?: number }): Promise { - const browser = await launchBrowser({ ...(options.chrome ? { chrome: options.chrome } : {}), headless: options.headless, timeoutMs: options.timeoutMs ?? 30_000 }); +/** + * One Chrome profile kept between runs: once the browser has passed FTB's bot + * check its cookie stays good, so a later login POST is not challenged and + * resubmitted without its password (which FTB counts as a failed attempt). + */ +export async function openSession(options: { chrome?: string | undefined; headless: boolean; timeoutMs?: number; profile?: string | undefined }): Promise { + if (options.profile) mkdirSync(options.profile, { recursive: true, mode: 0o700 }); + const browser = await launchBrowser({ + ...(options.chrome ? { chrome: options.chrome } : {}), + ...(options.profile ? { profile: options.profile } : {}), + headless: options.headless, + timeoutMs: options.timeoutMs ?? 30_000, + }); const { cdp } = browser; const { targetInfos } = (await cdp.send('Target.getTargets')) as { targetInfos: { targetId: string; type: string }[] }; let targetId = targetInfos.find((target) => target.type === 'page')?.targetId; @@ -141,6 +156,25 @@ export async function goto(session: Session, url: string, timeoutMs = 30_000): P await sleep(1_500); } +/** + * Akamai's "Challenge Validation" interstitial is a proof-of-work the page's own + * script solves in ~20-30 s before resubmitting; a person waits it out too. + */ +async function waitOutChallenge(session: Session, say: (line: string) => void, timeoutMs = 90_000): Promise { + const challenged = `document.title === 'Challenge Validation' || !!document.getElementById('sec-cpt-if')`; + if (!(await evaluate(session, challenged))) return; + say(' (FTB bot check: waiting for the browser to finish it)'); + const until = Date.now() + timeoutMs; + while (Date.now() < until) { + await sleep(3_000); + if ((await evaluate(session, challenged)) === false) { + await sleep(1_500); + return; + } + } + throw new FtbError(`FTB's bot check did not clear in ${timeoutMs / 1000}s`); +} + export async function readPage(session: Session): Promise { const page = await evaluate(session, READ_PAGE); if (!page) throw new FtbError('could not read the page'); @@ -150,16 +184,38 @@ export async function readPage(session: Session): Promise { export interface WalkOptions { plan: Plan; rules: Rule[]; - /** Fill the first page that takes typed values, print what would go, and stop before its Continue. */ + /** Walk every page up to the one with the declaration, fill it, print its labels, and stop before Submit. */ dryRun: boolean; /** Where each page's fields (never values) are appended, for tuning the rules. */ log: string; /** Ask at the terminal for a required field no rule fills. */ interactive: boolean; say: (line: string) => void; + /** Where a verification code can be dropped when nobody is at a terminal. */ + codeFile: string; timeoutMs?: number; } +/** + * The code FTB just texted: typed at a terminal, or written to `codeFile` by + * whoever has the phone (riotcoder, relaying a message). The browser session + * stays open meanwhile; the code expires in minutes, so the wait is 15. + */ +async function waitForCode(options: WalkOptions): Promise { + if (options.interactive) return promptLine('Verification code FTB sent: '); + rmSync(options.codeFile, { force: true }); + options.say(` WAITING for the code FTB texted: write it to ${options.codeFile} (15 minutes)`); + const until = Date.now() + 15 * 60_000; + while (Date.now() < until) { + await sleep(3_000); + if (!existsSync(options.codeFile)) continue; + const code = readFileSync(options.codeFile, 'utf8').trim(); + rmSync(options.codeFile, { force: true }); + if (/^\w{4,10}$/.test(code)) return code; + } + throw new FtbError('no verification code arrived in 15 minutes'); +} + export interface WalkResult { outcome: Outcome | 'dry-run'; page: Page; @@ -172,7 +228,9 @@ function describe(field: Field): string { function logPage(path: string, page: Page): void { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); const fields = page.fields.map(({ selector, type, label, required, options }) => ({ selector, type, label, required, options: options?.slice(0, 12) })); - appendFileSync(path, `${JSON.stringify({ at: new Date().toISOString(), url: page.url, title: page.title, errors: page.errors, fields })}\n`, { mode: 0o600 }); + // A page with nothing to fill is a result page; its words are the result. Form pages keep fields only. + const text = page.fields.length ? undefined : page.text.replace(/^.*?Franchise Tax Board\s*(e-Services)?/s, '').slice(0, 2000); + appendFileSync(path, `${JSON.stringify({ at: new Date().toISOString(), url: page.url, title: page.title, errors: page.errors, fields, text })}\n`, { mode: 0o600 }); } /** @@ -185,24 +243,40 @@ export async function walk(session: Session, options: WalkOptions): Promise 0)) return { outcome: 'rejected', page }; + // A wrong or late code leaves the code page up with an error: wait for the next code, do not end the run. + // The bot check fired on a POST and replayed it without its typed values: fill the page again, once. + if (page.url.includes('akamai-challenge-resubmit') && page.errors.length && !replayed) { + replayed = true; + say(' (the bot check replayed the form without its values; filling it again)'); + page.errors = []; + } + const codeRetry = page.errors.length > 0 && page.errors.every((e) => /code you entered/i.test(e)) && page.fields.some((f) => CODE_LABEL.test(f.label)); + if (codeRetry) say(` FTB: ${page.errors[0]}`); + const outcome = codeRetry ? 'continue' : outcomeOf(`${page.errors.join(' ')} ${page.errors.length ? '' : page.text}`); + if (outcome === 'rejected' || (page.errors.length && step > 0 && !codeRetry)) return { outcome: 'rejected', page }; if (outcome !== 'continue') return { outcome, page }; sameUrl = page.url === lastUrl ? sameUrl + 1 : 0; - if (sameUrl >= 2) throw new FtbError(`stuck on ${page.url}: the page did not move after Continue (see ${options.log})`); + if (sameUrl >= 2 && !codeRetry) throw new FtbError(`stuck on ${page.url}: the page did not move after Continue (see ${options.log})`); lastUrl = page.url; say(` ${page.title.split('|')[1]?.trim() || page.title} ${page.url}`); const filled: string[] = []; const missing: Field[] = []; + let declared = false; for (const pass of ['choices', 'text'] as const) { - if (pass === 'text') page = await readPage(session); + if (pass === 'text') { + // A select can rewrite the page around it (FTB's form type does); let that land first. + await sleep(2_500); + page = await readPage(session); + } for (const field of page.fields) { const choice = ['select-one', 'radio', 'checkbox'].includes(field.type); if ((pass === 'choices') !== choice) continue; @@ -212,7 +286,8 @@ export async function walk(session: Session, options: WalkOptions): Promise line.includes(' = '))) return { outcome: 'dry-run', page }; + // The page with the declaration is the one that creates the account; a dry run shows it and stops. + if (options.dryRun && declared) { + for (const field of page.fields) if (field.type !== 'checkbox') say(` [${field.label}]`); + return { outcome: 'dry-run', page }; + } const pressed = await evaluate(session, SUBMIT); - if (!pressed) throw new FtbError(`no Continue or Submit on ${page.url} (fields logged to ${options.log})`); + if (!pressed || pressed.startsWith('?')) { + throw new FtbError(`no forward button on ${page.url}; buttons seen: ${pressed?.slice(1) || 'none'} (fields logged to ${options.log})`); + } + say(` -> ${pressed}`); await settle(session, timeoutMs); } throw new FtbError(`gave up after 15 pages (see ${options.log})`); diff --git a/src/ftb.ts b/src/ftb.ts index d212f07..067617c 100644 --- a/src/ftb.ts +++ b/src/ftb.ts @@ -17,8 +17,7 @@ * - **People only where the law or the post office needs one.** The penalty-of- * perjury box is ticked only with `--declare` (the person's own statement), * one shared secret is submitted per run and never retried, and the PIN step - * becomes a myna hand-off card (mynaposter.com/handoff/) that carries - * steps and never a secret. + * is tracked here (`ftb status`), never posted to any other service. * * Everything here is pure and tested; the browser lives in ftb-run.ts. */ @@ -280,10 +279,17 @@ export interface Plan { security: Record; /** The person's own penalty-of-perjury statement, from `--declare`. */ declare: boolean; + /** Ten digits; FTB texts a verification code to it during registration. */ + phone?: string | undefined; + /** Have FTB phone the code in rather than text it (a text can be throttled or lost). */ + call?: boolean | undefined; /** Activation only. */ pin?: string; } +/** The box FTB's texted (or spoken) verification code goes in. */ +export const CODE_LABEL = /verification code|security code|one[- ]time|passcode|access code|enter (the )?code/i; + function option(field: Field, pattern: RegExp): Action | null { const found = field.options?.find((o) => o.value !== '' && (pattern.test(o.text) || pattern.test(o.value))); return found ? { kind: 'select', value: found.value } : null; @@ -350,23 +356,34 @@ export function rulesFor(plan: Plan): Rule[] { // Role. { name: 'role', - label: plan.role === 'business' ? /business representative/i : /\bindividual\b/i, + label: /individual|business representative/i, types: ['radio'], - act: () => ({ kind: 'check' }), + // By how the option starts: "Tax Professional - ... your individual or business entity client" names both. + act: (f) => ((plan.role === 'business' ? /^\s*business representative/i : /^\s*individual\b/i).test(f.label) ? { kind: 'check' } : null), }, // Address on file. - { name: 'address numbers', label: /numbers? in .*address|address.*numbers?/i, types: SUBMIT_TYPES, act: text(plan.addressNumbers) }, - { name: 'zip', label: /zip/i, types: SUBMIT_TYPES, act: text(plan.zip) }, + { name: 'zip', label: /zip|postal/i, types: SUBMIT_TYPES, act: text(plan.zip) }, + { name: 'address numbers', label: /numbers in (the |your )?(business )?(mailing )?address/i, types: SUBMIT_TYPES, act: text(plan.addressNumbers) }, // The shared secret. - { name: 'tax year', label: /year/i, types: ['select-one'], act: (f) => option(f, new RegExp(`^\\s*${plan.year}\\s*$`)) }, - { name: 'tax year', label: /year/i, types: ['text', 'number', 'tel'], act: text(String(plan.year)) }, + { name: 'tax year', label: /year (of|on) the tax return|tax year/i, types: ['select-one'], act: (f) => option(f, new RegExp(`^\\s*${plan.year}\\s*$`)) }, + { name: 'tax year', label: /year (of|on) the tax return|tax year/i, types: ['text', 'number', 'tel'], act: text(String(plan.year)) }, { name: 'net income', label: /net income|income \(loss\)|adjusted gross|\bagi\b/i, types: SUBMIT_TYPES, act: text(formatAmount(plan.amount)) }, // The penalty-of-perjury statement: only ever the person's own. { name: 'declaration', label: /perjury|i declare|under penalty/i, types: ['checkbox'], act: () => ({ kind: 'declare' }) }, + // Phone verification: a text by default, a call with --call. + { name: 'phone', label: /phone number/i, types: SUBMIT_TYPES, act: () => (plan.phone ? { kind: 'text', value: plan.phone } : null) }, + { name: plan.call ? 'call me' : 'send a text', label: /send me a text|text message|call me/i, types: ['radio'], act: (f) => ((plan.call ? /call me/i : /text/i).test(f.label) ? { kind: 'check' } : null) }, + { name: 'foreign number', id: /^Phone_Foreign$/, act: () => null }, + { name: 'foreign address', id: /^Address_Foreign$|^Address_No(MailAddress|PostalCode)$/, act: () => null }, + + // Login. + { name: 'user name', label: /^user name/i, types: ['text'], act: text(plan.username) }, + { name: 'password', label: /^password/i, types: ['password'], act: text(plan.password, true) }, + // Activation. { name: 'pin', label: /\bpin\b|personal identification number/i, types: SUBMIT_TYPES, act: () => (plan.pin ? { kind: 'text', value: plan.pin, secret: true } : null) }, ]; @@ -393,7 +410,7 @@ export function rulesFor(plan: Plan): Rule[] { return { kind: 'text', value: part && slices[part] !== undefined ? slices[part]! : digits, secret: true }; }, }, - { name: 'form type', label: /form type|type of (tax )?(return|form)/i, types: ['select-one'], act: (f) => option(f, /^\s*540\b(?!\s*NR)/i) }, + { name: 'form type', label: /form type|type of (tax )?(return|form)/i, types: ['select-one'], act: (f) => option(f, /^\s*(form\s*)?540\s*$/i) }, ); } return rules; @@ -409,7 +426,8 @@ export function decide(rules: readonly Rule[], field: Field): { rule: Rule; acti if (!pattern) continue; if (!(pass === 'id' ? pattern.test(field.id) : pattern.test(haystack))) continue; const action = rule.act(field); - return action ? { rule, action } : null; + // An id match is deliberate, even when it says "leave this alone"; a label match that cannot act lets the next rule try. + if (action || pass === 'id') return action ? { rule, action } : null; } } return null; @@ -422,7 +440,7 @@ export function decide(rules: readonly Rule[], field: Field): { rule: Rule; acti export type Outcome = 'registered' | 'activated' | 'rejected' | 'continue'; export function outcomeOf(text: string): Outcome { - if (/does not match our records|there is a problem|unable to (verify|process) your|account (is|has been) locked/i.test(text)) return 'rejected'; + if (/does not match our records|there is a problem|unable to (verify|process) your|account (is|has been) locked|account locked|exceeded the allowed number of attempts/i.test(text)) return 'rejected'; if (/account (has been )?activated|activation (is )?complete/i.test(text)) return 'activated'; if (/registration confirmation|successfully (registered|created)|we will (mail|send) you a (letter|pin)|pin .*(mail|letter)/i.test(text)) return 'registered'; return 'continue'; @@ -441,7 +459,6 @@ export interface Account { secret: { year: number; form: string; amount: number }; registeredAt: string; activatedAt?: string; - handoff?: string; } export interface State { @@ -474,16 +491,76 @@ export function toEnvFile(values: Record): string { .join('\n')}\n`; } -/** The myna card for the PIN letter. Steps only: no PIN, password or SSN ever goes on it. */ -export function handoffCard(role: Role, registeredAt: Date): { title: string; steps: string[]; text: string } { - const deadline = new Date(registeredAt.getTime() + 21 * 86_400_000).toISOString().slice(0, 10); +/** FTB's PIN expires 21 days after registration. */ +export function activationDeadline(registeredAt: string): string { + return new Date(Date.parse(registeredAt) + 21 * 86_400_000).toISOString().slice(0, 10); +} + +// --------------------------------------------------------------------------- +// Throttle: FTB restarts a 30-minute lock on every attempt made inside it, so +// a careless retry loop turns one lockout into an open-ended one. +// --------------------------------------------------------------------------- + +export interface Throttle { + /** Every run that sent something to FTB for an account: register, login, activate. */ + attempts: { role: Role; at: string; kind: string }[]; + /** Per account, the time before which nothing may be sent at all. */ + lockedUntil: Partial>; +} + +export const LIMITS = { + /** FTB's lockout is 30 minutes; 5 more so a clock skew cannot restart it. */ + lockoutMs: 35 * 60_000, + windowMs: 30 * 60_000, + perWindow: 2, + perDay: 4, + /** Between any two runs, whichever account: FTB sees one browser and one phone. */ + spacingMs: 2 * 60_000, +} as const; + +export function emptyThrottle(): Throttle { + return { attempts: [], lockedUntil: {} }; +} + +/** + * Whether a run for `role` may start now. A lockout is absolute; the window, + * daily and spacing limits give way to `force`. + */ +export function checkThrottle(throttle: Throttle, role: Role, now: Date, force = false): { ok: true } | { ok: false; until: Date; reason: string } { + const t = now.getTime(); + const locked = throttle.lockedUntil[role]; + if (locked && Date.parse(locked) > t) { + return { ok: false, until: new Date(locked), reason: `FTB locked the ${role} account; any attempt before then restarts the lock` }; + } + if (force) return { ok: true }; + const all = throttle.attempts.map((a) => ({ ...a, ms: Date.parse(a.at) })).filter((a) => t - a.ms < 86_400_000); + const last = Math.max(0, ...all.map((a) => a.ms)); + if (last && t - last < LIMITS.spacingMs) { + return { ok: false, until: new Date(last + LIMITS.spacingMs), reason: 'runs are spaced 2 minutes apart' }; + } + const mine = all.filter((a) => a.role === role).sort((a, b) => a.ms - b.ms); + const recent = mine.filter((a) => t - a.ms < LIMITS.windowMs); + if (recent.length >= LIMITS.perWindow) { + return { ok: false, until: new Date(recent[0]!.ms + LIMITS.windowMs), reason: `${LIMITS.perWindow} ${role} attempts in 30 minutes` }; + } + if (mine.length >= LIMITS.perDay) { + return { ok: false, until: new Date(mine[0]!.ms + 86_400_000), reason: `${LIMITS.perDay} ${role} attempts today` }; + } + return { ok: true }; +} + +export function recordAttempt(throttle: Throttle, role: Role, kind: string, now: Date): Throttle { + const dayAgo = now.getTime() - 86_400_000; return { - title: `FTB PIN letter: ${role} MyFTB account`, - steps: [ - `Watch the mail at the address FTB has on file for the MyFTB PIN letter (5 to 10 business days).`, - `Activate before ${deadline}: the PIN expires 21 days after registration.`, - `Run the command below on the dev box with the PIN, or send the PIN to riotcoder to run it.`, - ], - text: `ftb activate ${role} --pin \n`, + ...throttle, + attempts: [...throttle.attempts.filter((a) => Date.parse(a.at) > dayAgo), { role, kind, at: now.toISOString() }], }; } + +export function recordLockout(throttle: Throttle, role: Role, now: Date): Throttle { + return { ...throttle, lockedUntil: { ...throttle.lockedUntil, [role]: new Date(now.getTime() + LIMITS.lockoutMs).toISOString() } }; +} + +export function isLockout(text: string): boolean { + return /account locked|exceeded the allowed number of attempts/i.test(text); +} diff --git a/test/ftb.test.ts b/test/ftb.test.ts index 586edd7..9471fbd 100644 --- a/test/ftb.test.ts +++ b/test/ftb.test.ts @@ -11,7 +11,7 @@ import { formatAmount, generatePassword, generateUsername, - handoffCard, + activationDeadline, latestIdentity, maskSsn, outcomeOf, @@ -20,6 +20,11 @@ import { rulesFor, toEnvFile, vaultKeys, + checkThrottle, + emptyThrottle, + isLockout, + recordAttempt, + recordLockout, } from '../src/ftb.ts'; const NOW = new Date('2026-10-04T12:00:00Z'); @@ -178,22 +183,110 @@ describe('what a page says', () => { expect(outcomeOf('There is a problem. Check below. The information you entered does not match our records.')).toBe('rejected'); expect(outcomeOf('Registration Confirmation. We will mail you a letter with your PIN.')).toBe('registered'); expect(outcomeOf('Your account has been activated.')).toBe('activated'); + expect(outcomeOf('Account Locked. You exceeded the allowed number of attempts.')).toBe('rejected'); expect(outcomeOf('Business Representative Registration. Enter the numbers in your mailing address.')).toBe('continue'); }); }); -describe('storage and the hand-off card', () => { +describe('storage and the PIN deadline', () => { const account = { role: 'business' as const, username: 'u', password: 'p#$ "x', email: 'e@x.y', security: { 'Q?': 'a' }, secret: { year: 2024, form: '100S', amount: 0 }, registeredAt: NOW.toISOString() }; it('quotes every .env value so # and $ survive', () => { expect(toEnvFile(vaultKeys(account))).toContain('FTB_BUSINESS_PASSWORD="p#$ \\"x"'); }); - it('puts steps and a deadline on the card, and never a secret', () => { - const card = handoffCard('business', NOW); - const all = [card.title, ...card.steps, card.text].join('\n'); - expect(all).toContain('2026-10-25'); - expect(all).toContain('ftb activate business --pin'); - expect(all).not.toMatch(/p#\$|password|\d{3}-\d{2}-\d{4}/i); + it('dates the PIN deadline 21 days after registration', () => { + expect(activationDeadline(NOW.toISOString())).toBe('2026-10-25'); + }); +}); + +describe('the business page as FTB serves it', () => { + it('fills ZIP and address numbers into their own boxes', () => { + const rules = rulesFor(plan()); + expect(decide(rules, field({ id: 'Address_PostalCode', label: 'US ZIP Code 5 Numbers only; if none, leave blank.' }))?.action).toMatchObject({ value: '94000' }); + expect(decide(rules, field({ id: 'Address_MailAddress', label: 'Numbers in Mailing Address Up to 6 numbers only; if none, leave blank.' }))?.action).toMatchObject({ value: '1234' }); + }); + + it('does not let "the year you chose" in the form-type label take the form type', () => { + const rules = rulesFor(plan()); + const form = field({ id: 'FormType', type: 'select-one', label: 'Tax form type filed for the year you chose', options: [{ value: '', text: '' }, { value: '100', text: 'Form 100' }, { value: '100S', text: 'Form 100S' }] }); + expect(decide(rules, form)?.action).toEqual({ kind: 'select', value: '100S' }); + expect(decide(rules, field({ id: 'EntityId', label: 'Entity ID 7, 9, 10, 11, or 12 numbers only' }))?.action).toMatchObject({ value: '1234567' }); + }); +}); + +describe('choose your role', () => { + const roles = [ + field({ id: 'r_Role0', type: 'radio', label: 'Individual - Access your personal income tax information.' }), + field({ id: 'r_Role1', type: 'radio', label: 'Business Representative - Access income tax information of the business entity.' }), + field({ id: 'r_Role2', type: 'radio', label: 'Tax Professional - Access income tax information on behalf of your individual or business entity client.' }), + ]; + + it('checks exactly one role, never Tax Professional', () => { + for (const role of ['personal', 'business'] as const) { + const rules = rulesFor(plan({ role })); + const checked = roles.filter((f) => decide(rules, f)?.action.kind === 'check').map((f) => f.id); + expect(checked).toEqual([role === 'personal' ? 'r_Role0' : 'r_Role1']); + } + }); +}); + +describe('the individual page as FTB serves it', () => { + it('takes the year, Form 540 (not 540NR or 2EZ) and the status', () => { + const rules = rulesFor(plan({ role: 'personal', corpId: undefined, ssn: '123-45-6789', filingStatus: 'single', amount: 98765, year: 2024 })); + const year = field({ id: 'ReturnYear', type: 'select-one', label: 'Year on the tax return filed', options: [{ value: '', text: '' }, { value: '2025', text: '2025' }, { value: '2024', text: '2024' }] }); + expect(decide(rules, year)?.action).toEqual({ kind: 'select', value: '2024' }); + const form = field({ id: 'TaxFormType', type: 'select-one', label: 'Tax form type filed for the year you chose', options: [{ value: '', text: '' }, { value: 'a', text: 'Form 540' }, { value: 'b', text: 'Form 540NR' }, { value: 'c', text: 'Form 540 2EZ' }] }); + expect(decide(rules, form)?.action).toEqual({ kind: 'select', value: 'a' }); + expect(decide(rules, field({ id: 'Agi', label: 'California adjusted gross income from line 17' }))?.action).toMatchObject({ value: '98765' }); + }); +}); + +describe('phone verification', () => { + const text = field({ id: 'r_Phone_Delivery0', type: 'radio', label: 'Send me a text message' }); + const call = field({ id: 'r_Phone_Delivery1', type: 'radio', label: 'Call me' }); + + it('picks text by default and call with --call', () => { + expect(decide(rulesFor(plan()), text)?.action).toEqual({ kind: 'check' }); + expect(decide(rulesFor(plan()), call)).toBeNull(); + expect(decide(rulesFor(plan({ call: true })), call)?.action).toEqual({ kind: 'check' }); + expect(decide(rulesFor(plan({ call: true })), text)).toBeNull(); + }); +}); + +describe('throttle', () => { + const at = (minutes: number) => new Date(NOW.getTime() + minutes * 60_000); + + it('allows a first attempt and spaces the next by 2 minutes', () => { + let t = emptyThrottle(); + expect(checkThrottle(t, 'personal', at(0)).ok).toBe(true); + t = recordAttempt(t, 'personal', 'register', at(0)); + expect(checkThrottle(t, 'business', at(1)).ok).toBe(false); + expect(checkThrottle(t, 'business', at(3)).ok).toBe(true); + }); + + it('allows 2 attempts per account per 30 minutes and 4 a day', () => { + let t = emptyThrottle(); + t = recordAttempt(t, 'personal', 'register', at(0)); + t = recordAttempt(t, 'personal', 'register', at(5)); + const third = checkThrottle(t, 'personal', at(10)); + expect(third.ok).toBe(false); + expect(third.ok ? 0 : third.until.getTime()).toBe(at(30).getTime()); + t = recordAttempt(t, 'personal', 'login', at(40)); + t = recordAttempt(t, 'personal', 'login', at(45)); + expect(checkThrottle(t, 'personal', at(100)).ok).toBe(false); + expect(checkThrottle(t, 'personal', at(100), true).ok).toBe(true); + }); + + it('never lets anything through a lockout, force included', () => { + const t = recordLockout(emptyThrottle(), 'personal', at(0)); + expect(checkThrottle(t, 'personal', at(34), true).ok).toBe(false); + expect(checkThrottle(t, 'personal', at(36)).ok).toBe(true); + expect(checkThrottle(t, 'business', at(1)).ok).toBe(true); + }); + + it('recognises FTB\'s lockout page', () => { + expect(isLockout('Account Locked You exceeded the allowed number of attempts.')).toBe(true); + expect(isLockout('Registration Confirmation')).toBe(false); }); });