morphit/apps/web/scripts/rpc-privacy-routing-smoke.ts

204 lines
9.4 KiB
TypeScript

/**
* rpc-privacy-routing-smoke (cp346)
*
* Pins the browser→chain routing policy so it can't silently regress in EITHER
* direction:
*
* PRIVACY (priority #1): reads that only need PUBLIC data and carry no trust
* weight must go through the SAME-ORIGIN indexer, so third-party RPC nodes
* never see the user's IP or which account they're touching. Today that's the
* pairing posting-key lookup (cp346 — fetchAccountKeys, never a direct
* condenser_api.get_accounts) and the seed-import reverse key→name lookup
* (cp351 accountByKey — POST /v1/chain/key-references, never a direct
* condenser_api.get_key_references). A general sweep also fails if ANY web
* source file makes a direct get_key_references call, so the next instance of
* this class can't slip past an enumerated allowlist (the gap that let the
* original accountByKey ship direct).
*
* TRUST — cp410 policy. PAYMENT, chat-IDENTITY and op-signature verification
* now route through the same-origin indexer (privacy #1); the browser's old
* multi-node quorum is gone. To keep the money-critical payment check
* trustless for the cautious, the chat UI offers an independent block-explorer
* "Verify" link instead. RELEASE verification is the SOLE exception: it still
* reads the real chain DIRECTLY (getDirectChainClient), because its anti-tamper
* trust anchor is meaningless if it trusts the operator's own indexer — a
* malicious operator could otherwise forge a "verified" release. This smoke
* fails if payment/identity/op verification regress to a direct-node read, if
* release verification is rerouted through the indexer, or if any flow OTHER
* than release verification uses the direct-to-chain reader.
*
* Static source scan (the rotator module pulls $app/environment, which the
* smoke runner can't resolve, so we assert on source rather than importing).
*/
import { readFileSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const webRoot = join(here, '..');
const read = (p: string): string => readFileSync(join(webRoot, p), 'utf8');
let passed = 0;
let failed = 0;
function check(label: string, cond: boolean): void {
if (cond) {
passed++;
console.log(` \u2713 ${label}`);
} else {
failed++;
console.log(` \u2717 ${label}`);
}
}
console.log('\nrpc-privacy-routing smoke:\n');
// ─── PRIVACY: pairing reads PUBLIC keys via the same-origin indexer ──────────
const pairingClient = read('src/lib/auth/pairingClient.ts');
const pairingPhone = read('src/lib/auth/pairingPhoneSigner.ts');
check(
'pairingClient fetches keys via the indexer (fetchAccountKeys), not direct RPC',
pairingClient.includes('fetchAccountKeys') &&
!/condenser_api\.get_accounts/.test(pairingClient)
);
check(
'pairingPhoneSigner fetches keys via the indexer (fetchAccountKeys), not direct RPC',
pairingPhone.includes('fetchAccountKeys') && !/condenser_api\.get_accounts/.test(pairingPhone)
);
// ─── PRIVACY: seed-import reverse key→name lookup is same-origin (cp351) ──────
const accountByKey = read('src/lib/blurt/accountByKey.ts');
check(
'accountByKey resolves via the same-origin indexer (POST /v1/chain/key-references)',
/\/v1\/chain\/key-references/.test(accountByKey) &&
/fetchWithTimeout/.test(accountByKey)
);
check(
'accountByKey does NOT call get_key_references directly (no browser→3rd-party RPC leak)',
!/['"](?:condenser_api\.)?get_key_references['"]/.test(accountByKey) &&
!/getBlurtClient/.test(accountByKey)
);
// General sweep: NO web source file may call condenser_api.get_key_references
// directly. accountByKey is the only key→name lookup and it must stay
// same-origin; an enumerated check (above) wouldn't catch a NEW file doing it
// direct — exactly how the original accountByKey shipped a direct call past
// the cp346 allowlist. Walk every .ts/.svelte under src/. A DIRECT call passes
// the method as a quoted string argument; a comment/doc mention uses backticks
// or bare prose, so the quoted-string pattern targets only real call sites.
function walk(dir: string, acc: string[]): string[] {
for (const ent of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, ent.name);
if (ent.isDirectory()) walk(full, acc);
else if (/\.(ts|svelte)$/.test(ent.name)) acc.push(full);
}
return acc;
}
const srcFiles = walk(join(webRoot, 'src'), []);
const directKeyRefOffenders = srcFiles.filter((f) =>
/['"](?:condenser_api\.)?get_key_references['"]/.test(readFileSync(f, 'utf8'))
);
check(
'no web source calls condenser_api.get_key_references directly (sweep)',
directKeyRefOffenders.length === 0
);
if (directKeyRefOffenders.length > 0) {
for (const f of directKeyRefOffenders) console.log(` ↳ direct get_key_references in ${f}`);
}
// The same-origin lookup needs the indexer endpoint to exist (else the import
// flow silently falls back to manual entry forever). Pin the proxy route.
const indexerChainExplorer = readFileSync(
join(webRoot, '..', 'indexer', 'src', 'api', 'chainExplorer.ts'),
'utf8'
);
check(
'indexer exposes the same-origin key-references proxy (POST /key-references + get_key_references)',
/['"]\/key-references['"]/.test(indexerChainExplorer) &&
/get_key_references/.test(indexerChainExplorer)
);
// ─── TRUST: payment / identity / op verification now route through the indexer ─
// cp410 — these used to read direct-to-chain across a multi-node quorum; they
// now go through the same-origin indexer relay (chainRelay), and must NOT use
// the node-hopping rotator or its callMany quorum any more.
const blurtVerify = read('src/lib/chat/blurtVerify.ts');
check(
'payment verification (blurtVerify) routes through the indexer relay (chainRelay), not the rotator',
/chainRelay/.test(blurtVerify) && !/getRotator\(\)/.test(blurtVerify) && !/\.callMany\b/.test(blurtVerify)
);
const chainOpVerify = read('src/lib/chat/chainOpVerify.ts');
check(
'op verification (chainOpVerify) routes through the indexer relay (chainRelay), not the rotator',
/chainRelay/.test(chainOpVerify) && !/getRotator\(\)/.test(chainOpVerify)
);
const chainVerify = read('src/lib/chat/chainVerify.ts');
check(
'chat-identity verification (chainVerify) routes through the indexer relay (chainRelay), not the rotator',
/chainRelay/.test(chainVerify) && !/getRotator\(\)/.test(chainVerify) && !/\.callMany\b/.test(chainVerify)
);
// ─── TRUST EXCEPTION: release verification stays DIRECT-to-chain ──────────────
// The sole sanctioned browser→node reader. Its anti-tamper anchor is worthless
// if it trusts the operator's own indexer (which could forge a "verified"
// release), so it MUST read the real chain via getDirectChainClient.
const releaseFetch = read('src/lib/net/releaseFetch.ts');
check(
'release verification (releaseFetch) reads direct-to-chain via getDirectChainClient',
/getDirectChainClient\(\)/.test(releaseFetch) && /getLatestCustomJson/.test(releaseFetch)
);
check(
'releaseFetch does NOT route through the indexer (no getBlurtClient, no /v1 relay)',
!/getBlurtClient\b/.test(releaseFetch) && !/\/v1\/(chain|account)/.test(releaseFetch)
);
check(
'releaseFetch documents WHY it avoids the indexer (trust anchor / forgery)',
/trust anchor/i.test(releaseFetch) && /forge/i.test(releaseFetch)
);
// getDirectChainClient is the ONE sanctioned browser→node reader. Sweep every
// web source: only releaseFetch may call it, and only blurt/client.ts (where it
// is defined and wired to the rotator) may reference it. A NEW file calling it
// would be a fresh direct-to-chain leak — fail loudly.
const directClientOffenders = srcFiles.filter((f) => {
if (f.endsWith('lib/blurt/client.ts') || f.endsWith('lib/net/releaseFetch.ts')) return false;
// Match a CALL `getDirectChainClient(` — a bare mention in a comment (e.g.
// endpoints.ts documenting why the rotator exists) is not a direct-chain read.
return /getDirectChainClient\(/.test(readFileSync(f, 'utf8'));
});
check('only releaseFetch uses the direct-to-chain client (sweep)', directClientOffenders.length === 0);
if (directClientOffenders.length > 0) {
for (const f of directClientOffenders) console.log(` ↳ direct-chain client used in ${f}`);
}
// ─── BROADCAST: same-origin indexer ONLY — no direct-to-node fallback ────────
// cp410 removed the cp344 direct-RPC fallback. Broadcasts go through
// /v1/broadcast and fail (BroadcastUnavailableError) if the indexer is
// unreachable — they never leak to a third-party node.
const broadcastTransport = read('src/lib/blurt/broadcastTransport.ts');
check(
'broadcastTransport broadcasts ONLY through the same-origin indexer (/v1/broadcast)',
/\/v1\/broadcast/.test(broadcastTransport)
);
check(
'broadcastTransport has NO direct-RPC fallback (no getBlurtClient, no directRpcBroadcast)',
!/getBlurtClient\b/.test(broadcastTransport) &&
!/directRpcBroadcast/.test(broadcastTransport) &&
/no direct-rpc fallback/i.test(broadcastTransport)
);
// ─── The indexer exposes the generic read-only condenser relay (cp410) ───────
check(
'indexer exposes the read-only condenser relay (POST /condenser, whitelisted)',
/['"]\/condenser['"]/.test(indexerChainExplorer) && /RELAYABLE_READ_METHODS/.test(indexerChainExplorer)
);
console.log('');
if (failed === 0) {
console.log(`\u2713 all ${passed} rpc-privacy-routing scenarios passed`);
} else {
console.log(`\u2717 ${failed} failed, ${passed} passed`);
process.exit(1);
}