148 lines
7.1 KiB
TypeScript
148 lines
7.1 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/*
|
|
* onchain-user-privacy — v1.5.0 (tt.txt I) guard.
|
|
*
|
|
* Ken: "ALL of this stuff that we are posting to the chain now, please
|
|
* remember to keep our users absolutely PRIVATE. please VERIFY that is so."
|
|
*
|
|
* Blurt is a PUBLIC, PERMANENT chain: anything broadcast in the clear is
|
|
* readable by anyone forever and can never be redacted. So every op that
|
|
* carries USER CONTENT must be either (a) encrypted client-side, or
|
|
* (b) deliberately public because the feature IS publication (a public
|
|
* order, a public review). This sentinel pins that split so a future edit
|
|
* can't quietly move something from column (a) to column (b).
|
|
*
|
|
* Existing neighbours (kept, not duplicated): chat-folders-onchain-smoke
|
|
* pins the folder op's own wiring; rpc-privacy-routing-smoke pins that the
|
|
* browser never talks to a third-party node. This one is the CROSS-OP
|
|
* invariant: what is allowed to be plaintext, per op.
|
|
*
|
|
* Metadata honesty: this does NOT claim chat metadata is hidden. Who
|
|
* messaged whom, when, roughly how big, and which order it concerns ARE
|
|
* plaintext on chain by design (the indexer must route + thread without
|
|
* decrypting). That is documented in the FAQ. What must NEVER be plaintext
|
|
* is the CONTENT: the message body, the shared crypto address + amount, the
|
|
* user's settings, and which threads they filed.
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const WEB = resolve(HERE, '..');
|
|
const REPO = resolve(WEB, '..', '..');
|
|
|
|
let pass = 0;
|
|
let fail = 0;
|
|
function ok(msg: string): void {
|
|
pass++;
|
|
console.log(` ✓ ${msg}`);
|
|
}
|
|
function bad(scope: string, msg: string): void {
|
|
fail++;
|
|
console.log(` ✗ ${scope}: ${msg}`);
|
|
}
|
|
const read = (p: string): string => readFileSync(resolve(REPO, p), 'utf8');
|
|
const flat = (s: string): string => s.replace(/\s+/g, ' ');
|
|
|
|
// ── 1. Chat body — ENCRYPTED (this is also how a shared crypto
|
|
// address + amount travels: inside the chat ciphertext). ────────
|
|
const chatSvc = flat(read('apps/web/src/lib/chat/chatService.ts'));
|
|
if (/ciphertext: envelope\.ciphertext/.test(chatSvc) && /encryptToRecipient/.test(chatSvc)) {
|
|
ok('chat: the on-chain body is ciphertext from encryptToRecipient (X25519 + ChaCha20-Poly1305)');
|
|
} else {
|
|
bad(
|
|
'chat',
|
|
'the chat op no longer broadcasts encryptToRecipient ciphertext — a message body (and every crypto address shared through chat) would be readable by the world, forever.'
|
|
);
|
|
}
|
|
// The self-copy (keep-history) must be ciphertext too — it is the sender's
|
|
// own message, and a plaintext copy leaks the same content.
|
|
if (/self_ciphertext: envelope\.selfCiphertext/.test(chatSvc)) {
|
|
ok('chat: the sender self-copy is ciphertext too (not a plaintext archive)');
|
|
} else {
|
|
bad('chat', 'the sender self-copy is no longer ciphertext — it would leak the same content the main body protects.');
|
|
}
|
|
|
|
// ── 2. Shared crypto address — never broadcast outside chat ─────────
|
|
const addrModal = flat(read('apps/web/src/lib/components/AddressShareModal.svelte'));
|
|
if (/await onShare\(wire\)/.test(addrModal)) {
|
|
ok('address share: leaves ONLY via onShare → the encrypted chat path');
|
|
} else {
|
|
bad('address share', 'the modal no longer hands its payload to onShare — verify it has not gained a direct broadcast that would bypass chat encryption.');
|
|
}
|
|
if (/broadcastCustomJson|broadcast_transaction/.test(addrModal)) {
|
|
bad(
|
|
'address share',
|
|
'the modal now broadcasts directly. A crypto address must travel ONLY inside chat ciphertext — a direct custom_json would publish it in the clear, permanently.'
|
|
);
|
|
} else {
|
|
ok('address share: the modal never broadcasts a custom_json itself');
|
|
}
|
|
|
|
// ── 3. Address history — device-local, never transmitted ────────────
|
|
// The moment Morphit learns "user X uses address Y", it becomes a
|
|
// correlation database. It must stay in localStorage.
|
|
const addrHist = read('apps/web/src/lib/privacy/addressHistory.ts');
|
|
const addrHistCode = addrHist.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
|
|
if (/localStorage/.test(addrHistCode) && !/\bfetch\s*\(/.test(addrHistCode) && !/broadcast/i.test(addrHistCode)) {
|
|
ok('address history: localStorage only — never fetched, never broadcast');
|
|
} else {
|
|
bad(
|
|
'address history',
|
|
'the address history module gained a fetch/broadcast. It records which addresses a user has shared; transmitting it would build exactly the correlation database Morphit exists to avoid.'
|
|
);
|
|
}
|
|
|
|
// ── 4. Settings + chat folders — ENCRYPTED blobs ────────────────────
|
|
// Folders name the PEERS and ORDERS a user filed: plaintext would expose
|
|
// their whole counterparty graph on a public chain.
|
|
const folders = flat(read('apps/web/src/lib/blurt/ops/chatFolders.ts'));
|
|
if (/encryptFolderState/.test(folders) && /\{ v: 1, enc \}/.test(folders)) {
|
|
ok('chat folders: broadcast as { v: 1, enc } — the filed peers/orders are never in the clear');
|
|
} else {
|
|
bad('chat folders', 'the folder op no longer broadcasts an encrypted blob — a user\'s counterparty graph would be public.');
|
|
}
|
|
const settings = flat(read('apps/web/src/lib/blurt/ops/settings.ts'));
|
|
if (/encryptSettingsState/.test(settings) && /enc/.test(settings)) {
|
|
ok('settings: broadcast as an encrypted blob (operator stores only opaque bytes)');
|
|
} else {
|
|
bad('settings', 'the settings op no longer broadcasts an encrypted blob.');
|
|
}
|
|
|
|
// ── 5. Public-by-design ops must still redact secrets ───────────────
|
|
// A review reply IS publication — but a user pasting a private key into
|
|
// the box must never publish it.
|
|
const reply = flat(read('apps/web/src/lib/components/RespondToFeedbackForm.svelte'));
|
|
if (/const outgoing = redactPrivateKeys\(comment\)/.test(reply) && /comment: outgoing/.test(reply)) {
|
|
ok('feedback reply: private keys are redacted BEFORE the (public, permanent) broadcast');
|
|
} else {
|
|
bad(
|
|
'feedback reply',
|
|
'the reply no longer redacts private keys before broadcasting. This op is public + permanent — a pasted key would be published irrevocably.'
|
|
);
|
|
}
|
|
|
|
// ── 6. The public RPC-health endpoint leaks no raw error text ───────
|
|
// cp471 added failure reasons. A raw error message can carry internal
|
|
// paths/IPs/hostnames, and this endpoint is public + unauthenticated.
|
|
const rpcHealth = read('apps/indexer/src/api/rpcHealth.ts');
|
|
const rpcCode = rpcHealth.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
|
|
if (/failure_reason/.test(rpcCode) && !/\.message/.test(rpcCode) && !/String\(err\)/.test(rpcCode)) {
|
|
ok('rpc health: publishes only the reason enum + numeric status — never a raw error message');
|
|
} else {
|
|
bad(
|
|
'rpc health',
|
|
'the public /v1/rpc-endpoints response may now carry raw error text (.message / String(err)). Raw errors can leak internal paths, IPs or upstream hostnames on an unauthenticated endpoint — publish the enum only.'
|
|
);
|
|
}
|
|
|
|
console.log('\n' + '─'.repeat(56));
|
|
if (fail === 0) {
|
|
console.log(`✓ all ${pass} scenarios passed`);
|
|
process.exit(0);
|
|
} else {
|
|
console.log(`✗ ${fail} of ${pass + fail} scenarios FAILED`);
|
|
process.exit(1);
|
|
}
|