morphit/scripts/no-master-password-in-fee-flows-smoke.ts

271 lines
12 KiB
TypeScript
Executable file

#!/usr/bin/env tsx
/**
* scripts/no-master-password-in-fee-flows-smoke.ts
*
* v1.8.15 (cp555) — SECURITY guard: Morphit never asks for, accepts, or even
* mentions a master password.
*
* THE POLICY (Ken, stated three times). A "master password" is the pre-fork
* Blurt account secret from which EVERY role's key is derived — owner included.
* Typing it into a web form hands over the key that can steal the whole account.
* Morphit's own "Morphit password" (which unlocks the LOCAL keystore) is a
* completely different, safe thing. Security is priority #2, behind privacy #1,
* and this line is absolute: the active-key unlock flow accepts an Active-key
* WIF and nothing else; a non-WIF string is refused outright, never tried as an
* account-wide secret.
*
* THE BUG THIS ALSO LOCKS THE FIX FOR. A posting-only user could not pay the
* first-contact fee: StrangerFeeModal only offered the Morphit-password path,
* which for a posting-only session has no active-key envelope and dead-ended at
* "Payment could not be broadcast." The fix renders the SAME UnlockActiveKeyModal
* the "Pay now" flow uses, so the user pastes their Active key (WIF) — plus their
* Morphit password if they keep it — in one modal, and we sign with the ephemeral
* scalar then wipe it.
*
* WHAT THIS SMOKE ASSERTS (structural — fails at lint time if any of it regresses):
* 1. the masterPassword.ts crypto module is GONE.
* 2. the money gate (activeKeyUnlock.ts) carries no master-password machinery:
* classifySecret is WIF-only ('wif'|'not_wif'|'empty'), UnlockResult.source
* is 'wif' only, and a non-WIF secret is refused as invalid_wif.
* 3. the three fee flows (StrangerFeeModal, PayBlurtModal, post form) import
* nothing master-password and reference no master-password symbol.
* 4. StrangerFeeModal wires UnlockActiveKeyModal + the ephemeral-scalar path,
* so a posting-only user can actually pay.
* 5. NOWHERE in apps/web/src (code, comment-stripped) does the token
* masterPassword / master_password survive.
* 6. NO locale advertises a "master password" (the term is scrubbed from all
* user-facing copy); the reassurance key was renamed to no_account_wide_password.
*
* COMMENTS ARE STRIPPED BEFORE GREPPING CODE. This guard's evidence is the code,
* and a guard about a term necessarily names that term in its own prose; a raw
* grep would trip on this very file and on explanatory comments. The blunt
* stripper can only REMOVE evidence, so its failure mode is a false ALARM
* (noisy, fixed), never a false pass. Locale JSON has no comments and is grepped
* raw.
*/
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
import { dirname, resolve, join, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
// Canonical locale list — never hardcode the set (locale-source-of-truth-smoke
// enforces this). locales.ts is a pure-data module (no imports), safe in Node.
import { SUPPORTED_LOCALES } from '../apps/web/src/lib/i18n/locales';
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(HERE, '..');
/** Blunt comment strip (block + line, with a `:` guard so `https://` survives). */
const stripComments = (src: string): string =>
src.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
const readStripped = (rel: string): string => stripComments(readFileSync(resolve(REPO, rel), 'utf8'));
interface Scenario {
name: string;
ok: boolean;
detail?: string;
}
const results: Scenario[] = [];
const check = (name: string, ok: boolean, detail?: string): void => {
results.push({ name, ok, detail });
};
console.log('\n── no-master-password / posting-only-fee security smoke ───\n');
// ── 1. the module is gone ──────────────────────────────
const MASTER_MODULE = 'apps/web/src/lib/crypto/masterPassword.ts';
check(
'masterPassword.ts crypto module is deleted',
!existsSync(resolve(REPO, MASTER_MODULE)),
`${MASTER_MODULE} still exists — the master-password derivation machinery must not be in the tree`
);
// ── 2. the money gate is WIF-only ──────────────────────
const GATE = 'apps/web/src/lib/crypto/activeKeyUnlock.ts';
const gate = readStripped(GATE);
check('activeKeyUnlock has no masterPassword symbol', !/masterPassword/i.test(gate), GATE);
check(
'activeKeyUnlock classifySecret is WIF-only (wif|not_wif|empty)',
/classifySecret\s*\([^)]*\)\s*:\s*'wif'\s*\|\s*'not_wif'\s*\|\s*'empty'/.test(gate),
`${GATE} — classifySecret must not return 'master_password'`
);
check(
'activeKeyUnlock UnlockResult.source is only "wif"',
/source:\s*'wif'\s*}/.test(gate) && !/source:\s*'wif'\s*\|\s*'master_password'/.test(gate),
GATE
);
{
// The resolver must refuse a non-WIF outright as invalid_wif (never derive
// keys from a passphrase). Scope to the resolveActiveKey body.
const fnMatch = gate.match(/export\s+async\s+function\s+resolveActiveKey[\s\S]*?\n}/);
const fnBody = fnMatch ? fnMatch[0] : '';
check(
'resolveActiveKey refuses a non-WIF secret as invalid_wif',
/kind\s*===\s*'not_wif'[\s\S]*?reason:\s*'invalid_wif'/.test(fnBody),
`${GATE} — a non-WIF secret must map to invalid_wif, not be tried as an account-wide secret`
);
check(
'resolveActiveKey body has no master-password derivation',
!/masterPassword/i.test(fnBody),
GATE
);
}
// ── 3. fee flows reference nothing master-password ─────
const FEE_FLOWS = [
'apps/web/src/lib/components/StrangerFeeModal.svelte',
'apps/web/src/lib/components/PayBlurtModal.svelte',
'apps/web/src/routes/[lang]/post/+page.svelte'
];
for (const rel of FEE_FLOWS) {
const src = readStripped(rel);
check(`${rel.split('/').pop()} references no masterPassword symbol`, !/masterPassword/i.test(src), rel);
}
// ── 4. StrangerFeeModal wires the posting-only path ────
const SFM = 'apps/web/src/lib/components/StrangerFeeModal.svelte';
const sfm = readStripped(SFM);
check(
'StrangerFeeModal imports UnlockActiveKeyModal',
/import\s+UnlockActiveKeyModal\s+from/.test(sfm),
SFM
);
check(
'StrangerFeeModal renders <UnlockActiveKeyModal> for the no-active-key case',
/<UnlockActiveKeyModal\b/.test(sfm) && /\{:else\}[\s\S]*<UnlockActiveKeyModal\b/.test(sfm),
SFM
);
check(
'StrangerFeeModal has the ephemeral-scalar signer (payWithEphemeralActiveKey)',
/function\s+payWithEphemeralActiveKey\s*\(/.test(sfm),
SFM
);
check(
'StrangerFeeModal wipes the ephemeral scalar (sodium.memzero)',
/sodium\.memzero\s*\(/.test(sfm),
SFM
);
check(
'StrangerFeeModal gates on hasActiveKey (activePublicKey)',
/hasActiveKey\s*=\s*\$derived\([\s\S]*activePublicKey/.test(sfm),
SFM
);
// ── 4b. active-key UX PARITY across EVERY spend flow ───
// Ken (t.txt #3): wherever an active key is needed, the spot must offer the
// SAME unlock UX the "Pay now" flow uses — Active-key WIF + optional Morphit
// password, one-time-use OR keep-encrypted — never a bare password field that
// dead-ends a posting-only session. This locks that invariant across all of
// them, so a NEW active-key flow can't ship with only the password path again.
const ACTIVE_KEY_FLOWS: Array<{ rel: string; ephemeral: RegExp }> = [
{ rel: 'apps/web/src/lib/components/PayBlurtModal.svelte', ephemeral: /payWithEphemeralActiveKey/ },
{ rel: 'apps/web/src/lib/components/StrangerFeeModal.svelte', ephemeral: /payWithEphemeralActiveKey/ },
{ rel: 'apps/web/src/lib/components/SendBlurtModal.svelte', ephemeral: /(payWithEphemeralActiveKey|EphemeralActiveKey)/ },
{ rel: 'apps/web/src/lib/components/PowerModal.svelte', ephemeral: /powerWithEphemeralActiveKey/ },
{ rel: 'apps/web/src/lib/components/FeatureBidForm.svelte', ephemeral: /bidWithEphemeralActiveKey/ }
];
for (const { rel, ephemeral } of ACTIVE_KEY_FLOWS) {
const src = readStripped(rel);
const short = rel.split('/').pop();
check(
`${short} renders <UnlockActiveKeyModal> for posting-only`,
/<UnlockActiveKeyModal\b/.test(src),
`${rel} — an active-key flow must offer the in-place unlock, not a password-only dead end`
);
check(
`${short} gates the unlock on hasActiveKey (activePublicKey)`,
/hasActiveKey\s*=\s*\$derived\([\s\S]*activePublicKey/.test(src),
rel
);
check(
`${short} signs posting-only with an ephemeral scalar and wipes it`,
ephemeral.test(src) && /sodium\.memzero\s*\(/.test(src),
`${rel} — the pasted Active key must be used once then memzero'd, never persisted here`
);
}
// The /post listing-fee step renders it via an overlay (showUnlockForFee).
{
const post = readStripped('apps/web/src/routes/[lang]/post/+page.svelte');
check(
'post form step 4 renders <UnlockActiveKeyModal> for the listing fee',
/<UnlockActiveKeyModal\b/.test(post) && /showUnlockForFee/.test(post),
'apps/web/src/routes/[lang]/post/+page.svelte — posting-only listing-fee payment must offer the unlock modal'
);
}
// ── 5. the token is gone everywhere in web src (stripped) ─
const WEB_SRC = resolve(REPO, 'apps/web/src');
const offenders: string[] = [];
const walk = (dir: string): void => {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
if (entry === 'node_modules' || entry.startsWith('.')) continue;
walk(full);
continue;
}
const ext = extname(entry);
if (ext !== '.ts' && ext !== '.svelte') continue;
if (/masterPassword|master_password/i.test(stripComments(readFileSync(full, 'utf8')))) {
offenders.push(full.slice(REPO.length + 1));
}
}
};
walk(WEB_SRC);
check(
'no masterPassword / master_password token anywhere in apps/web/src (code)',
offenders.length === 0,
offenders.length ? `offending files: ${offenders.join(', ')}` : undefined
);
// ── 6. locales carry no "master password" term ─────────
const LOCALES = resolve(REPO, 'apps/web/src/lib/i18n/locales');
const LANGS = SUPPORTED_LOCALES.map((l) => l.code);
// Translated forms of "master password" that must NOT appear in copy. (Chinese
// 主密钥/主密鑰 = master KEY appears legitimately in Monero subaddress copy, so we
// target the PASSWORD forms only: 主密码 / 主密碼.)
const TERMS = [
/master ?password/i,
/master-?passwort/i,
/contraseña maestra/i,
/mot de passe ma[iî]tre/i,
/主密码/,
/主密碼/,
/мастер-пароль/i,
/hasło główne/i,
/رمز اصلی/
];
const localeOffenders: string[] = [];
let renamedKeyPresent = true;
for (const lang of LANGS) {
const p = join(LOCALES, `${lang}.json`);
const raw = readFileSync(p, 'utf8');
if (TERMS.some((re) => re.test(raw))) localeOffenders.push(`${lang}.json`);
const parsed = JSON.parse(raw) as { backup_keys_panel?: Record<string, unknown> };
const panel = parsed.backup_keys_panel ?? {};
if (!('no_account_wide_password' in panel) || 'no_master_password' in panel) renamedKeyPresent = false;
}
check(
'no locale advertises a "master password" term',
localeOffenders.length === 0,
localeOffenders.length ? `offending locales: ${localeOffenders.join(', ')}` : undefined
);
check(
'backup panel reassurance key renamed to no_account_wide_password in all 10 locales',
renamedKeyPresent,
'some locale still has no_master_password or is missing no_account_wide_password'
);
// ── report ─────────────────────────────────────────────
const failed = results.filter((r) => !r.ok);
for (const r of results) {
console.log(` ${r.ok ? '✓' : '✗'} ${r.name}`);
if (!r.ok && r.detail) console.log(` ${r.detail}`);
}
console.log('\n──────────────────────────────────────────────────────');
if (failed.length > 0) {
console.log(`${failed.length}/${results.length} scenarios failed`);
process.exit(1);
}
console.log(`✓ all ${results.length} scenarios passed`);