morphit/apps/relay/scripts/canonical-message-cross-check-smoke.ts

448 lines
14 KiB
TypeScript

#!/usr/bin/env tsx
/**
* canonical-message-cross-check-smoke — runtime contract test
* for the Web Push subscribe signature canonical message format.
*
* Part 122 cp15 audit finding DD-5. The cp14 sig-verify
* canonical message format
*
* morphit:push:subscribe:<account>:<sha256_hex(endpoint)>:<timestamp>
*
* is defined in TWO places: the client in
* `apps/web/src/lib/notifications/push.ts` and the server in
* `apps/relay/src/policy/pushSubscribeSig.ts`. If someone
* changes the format on one side (different separator, different
* hash, different field order) without updating the other, the
* wiring-completeness smoke wouldn't catch it — it only does
* static-grep checks. The first failure would be in user-facing
* signature rejections.
*
* This smoke catches that drift by computing the canonical
* message via the SAME algorithm in two ways:
* 1. node:crypto (the server's path) using known fixed inputs
* 2. Web Crypto subtle (the client's path) via dynamically
* imported `crypto.subtle` (available in Node 22+)
* and asserts they produce byte-identical digests.
*
* Additionally, it round-trips a generated keypair through
* the dblurt PrivateKey.sign → PublicKey.verify path on a
* representative canonical message, verifying the signer side
* works end-to-end.
*/
import { createHash, webcrypto } from 'node:crypto';
import { PrivateKey } from '@beblurt/dblurt';
import { verifyPushSubscribeSignature } from '../src/policy/pushSubscribeSig.ts';
import type { BlurtClient } from '../src/blurt/client.ts';
interface Scenario {
readonly name: string;
readonly ok: boolean;
readonly detail?: string;
}
const results: Scenario[] = [];
function record(name: string, ok: boolean, detail?: string): void {
results.push({ name, ok, detail });
}
// ─── 1: canonical-message construction agrees ────────────────
const fixedAccount = 'alice.test';
const fixedEndpoint = 'https://fcm.googleapis.com/fcm/send/abc:DEFG_xyz';
const fixedTimestamp = 1747432800; // arbitrary
function buildServerCanonical(
account: string,
endpoint: string,
timestamp: number
): { canonical: string; messageHashHex: string } {
const endpointHash = createHash('sha256').update(endpoint, 'utf-8').digest('hex');
const canonical = `morphit:push:subscribe:${account}:${endpointHash}:${timestamp}`;
const messageHashHex = createHash('sha256').update(canonical, 'utf-8').digest('hex');
return { canonical, messageHashHex };
}
async function buildClientCanonical(
account: string,
endpoint: string,
timestamp: number
): Promise<{ canonical: string; messageHashHex: string }> {
// Mirrors the client's logic in apps/web/src/lib/notifications/push.ts.
const enc = new TextEncoder();
const endpointHashBuf = await webcrypto.subtle.digest('SHA-256', enc.encode(endpoint));
const endpointHashBytes = new Uint8Array(endpointHashBuf);
const endpointHashHex = Array.from(endpointHashBytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
const canonical = `morphit:push:subscribe:${account}:${endpointHashHex}:${timestamp}`;
const messageHashBuf = await webcrypto.subtle.digest('SHA-256', enc.encode(canonical));
const messageHashBytes = new Uint8Array(messageHashBuf);
const messageHashHex = Array.from(messageHashBytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return { canonical, messageHashHex };
}
{
const server = buildServerCanonical(fixedAccount, fixedEndpoint, fixedTimestamp);
const client = await buildClientCanonical(fixedAccount, fixedEndpoint, fixedTimestamp);
record(
'client + server compute identical canonical message',
server.canonical === client.canonical,
server.canonical !== client.canonical
? `server="${server.canonical}", client="${client.canonical}"`
: undefined
);
record(
'client + server compute identical SHA-256 of canonical',
server.messageHashHex === client.messageHashHex,
server.messageHashHex !== client.messageHashHex
? `server=${server.messageHashHex}, client=${client.messageHashHex}`
: undefined
);
}
// ─── 2: signature round-trip (sign with PrivateKey, verify
// via the actual server-side verifier) ────────────────
{
// Build a test keypair. dblurt's PrivateKey.fromSeed gives
// a deterministic key from a string — useful for repeatable
// smokes.
const privKey = PrivateKey.fromSeed('canonical-message-cross-check-test-seed');
const pubKey = privKey.createPublic();
const pubKeyStr = pubKey.toString();
// Build canonical message + hash.
const { messageHashHex } = buildServerCanonical(
fixedAccount,
fixedEndpoint,
fixedTimestamp
);
const messageHashBuf = Buffer.from(messageHashHex, 'hex');
// Sign.
const sig = privKey.sign(messageHashBuf);
const sigStr = sig.toString();
record('PrivateKey.sign produces a non-empty signature', sigStr.length > 0);
// Verify by feeding our verifier a stub BlurtClient that
// returns the test pubkey we just generated.
const stubBlurt = {
async getAccount(_name: string) {
return {
name: fixedAccount,
created: '',
balance: '0.000 BLURT',
pending_claimed_accounts: 0,
posting_pubkey: pubKeyStr
};
}
} as unknown as BlurtClient;
const verifyResult = await verifyPushSubscribeSignature(
stubBlurt,
{
account: fixedAccount,
endpoint: fixedEndpoint,
timestamp: fixedTimestamp,
signatureHex: sigStr
},
fixedTimestamp + 1 // current time, just past sig time, well within ±5min skew
);
record(
'verifyPushSubscribeSignature accepts a freshly signed message',
verifyResult.ok === true,
verifyResult.ok === false ? `reason=${verifyResult.reason}` : undefined
);
// Negative case: tamper with the timestamp on the wire, verifier
// rejects.
const tamperResult = await verifyPushSubscribeSignature(
stubBlurt,
{
account: fixedAccount,
endpoint: fixedEndpoint,
timestamp: fixedTimestamp + 1, // ≠ what was signed
signatureHex: sigStr
},
fixedTimestamp + 2
);
record(
'verifier rejects a signature when timestamp was tampered',
tamperResult.ok === false && tamperResult.reason === 'signature_mismatch'
);
// Negative case: clock skew beyond the ±5min window.
const stale = await verifyPushSubscribeSignature(
stubBlurt,
{
account: fixedAccount,
endpoint: fixedEndpoint,
timestamp: fixedTimestamp,
signatureHex: sigStr
},
fixedTimestamp + 6 * 60 // 6 minutes in the future
);
record(
'verifier rejects a signature beyond ±5min skew',
stale.ok === false && stale.reason === 'timestamp_out_of_range'
);
// Negative case: account-binding in canonical message.
// Caller claims to be 'bob.test' but submits a signature
// signed for the canonical containing 'alice.test'. The
// verifier reconstructs canonical with 'bob.test' and
// verifies against the pubkey returned for 'bob.test'.
// Even when the stub returns the same pubkey for any
// account (so we're not testing pubkey-lookup correctness,
// we're testing canonical-message account-binding), the
// verifier rejects because the signed digest doesn't match
// the digest reconstructed from 'bob.test'.
const wrongAccount = await verifyPushSubscribeSignature(
stubBlurt,
{
account: 'bob.test',
endpoint: fixedEndpoint,
timestamp: fixedTimestamp,
signatureHex: sigStr
},
fixedTimestamp + 1
);
record(
'verifier rejects signatures whose canonical was bound to a different account',
wrongAccount.ok === false && wrongAccount.reason === 'signature_mismatch'
);
// Negative case: endpoint-binding in canonical message.
// Same logic — the verifier reconstructs canonical from
// the submitted endpoint string and the signed digest
// won't match if any byte of the endpoint changed.
const wrongEndpoint = await verifyPushSubscribeSignature(
stubBlurt,
{
account: fixedAccount,
endpoint: 'https://different.push.service/abc',
timestamp: fixedTimestamp,
signatureHex: sigStr
},
fixedTimestamp + 1
);
record(
'verifier rejects a signature replayed against a different endpoint',
wrongEndpoint.ok === false && wrongEndpoint.reason === 'signature_mismatch'
);
// Negative case: malformed signature.
const malformed = await verifyPushSubscribeSignature(
stubBlurt,
{
account: fixedAccount,
endpoint: fixedEndpoint,
timestamp: fixedTimestamp,
signatureHex: 'not-a-real-signature'
},
fixedTimestamp + 1
);
record(
'verifier rejects malformed signatures',
malformed.ok === false && malformed.reason === 'malformed_signature'
);
// Negative case: unknown account (stub returns null).
const stubMissing = {
async getAccount(_name: string) {
return null;
}
} as unknown as BlurtClient;
const unknown = await verifyPushSubscribeSignature(
stubMissing,
{
account: fixedAccount,
endpoint: fixedEndpoint,
timestamp: fixedTimestamp,
signatureHex: sigStr
},
fixedTimestamp + 1
);
record(
'verifier rejects unknown accounts',
unknown.ok === false && unknown.reason === 'unknown_account'
);
// Negative case: account exists but has no posting key (chain
// returned an empty authority — defensive against future schema
// drift).
const stubNoKey = {
async getAccount(_name: string) {
return {
name: fixedAccount,
created: '',
balance: '0.000 BLURT',
pending_claimed_accounts: 0,
posting_pubkey: undefined
};
}
} as unknown as BlurtClient;
const noKey = await verifyPushSubscribeSignature(
stubNoKey,
{
account: fixedAccount,
endpoint: fixedEndpoint,
timestamp: fixedTimestamp,
signatureHex: sigStr
},
fixedTimestamp + 1
);
record(
'verifier rejects accounts with no posting key on chain',
noKey.ok === false && noKey.reason === 'no_posting_key_on_chain'
);
}
// ─── cp131 MED-009 — unsubscribe signature mirror + ACTION-binding
// replay defense ─────────────────────────────────────────────
{
const { verifyPushUnsubscribeSignature } = await import(
'../src/policy/pushSubscribeSig.ts'
);
const privKey = PrivateKey.fromSeed('cp131-MED-009-unsubscribe-seed');
const pubKey = privKey.createPublic();
const pubKeyStr = pubKey.toString();
const stubBlurt = {
async getAccount(_name: string) {
return {
name: fixedAccount,
created: '',
balance: '0.000 BLURT',
pending_claimed_accounts: 0,
posting_pubkey: pubKeyStr
};
}
} as unknown as BlurtClient;
// Build the canonical message for the UNSUBSCRIBE action and sign.
const endpointHashHex = createHash('sha256')
.update(fixedEndpoint, 'utf-8')
.digest('hex');
const unsubCanonical = `morphit:push:unsubscribe:${fixedAccount}:${endpointHashHex}:${fixedTimestamp}`;
const unsubHashBuf = createHash('sha256').update(unsubCanonical, 'utf-8').digest();
const unsubSig = privKey.sign(unsubHashBuf as unknown as Buffer).toString();
// Scenario: a signature produced over the unsubscribe canonical
// passes the unsubscribe verifier.
const unsubOk = await verifyPushUnsubscribeSignature(
stubBlurt,
{
account: fixedAccount,
endpoint: fixedEndpoint,
timestamp: fixedTimestamp,
signatureHex: unsubSig
},
fixedTimestamp + 1
);
record(
'cp131 MED-009 — verifyPushUnsubscribeSignature accepts a freshly signed message',
unsubOk.ok === true,
unsubOk.ok === false ? `reason=${unsubOk.reason}` : undefined
);
// Scenario: a SUBSCRIBE-action signature MUST NOT be accepted
// as an unsubscribe (cross-action replay defense). Sign over
// the subscribe canonical, present to unsubscribe verifier.
const subCanonical = `morphit:push:subscribe:${fixedAccount}:${endpointHashHex}:${fixedTimestamp}`;
const subHashBuf = createHash('sha256').update(subCanonical, 'utf-8').digest();
const subSig = privKey.sign(subHashBuf as unknown as Buffer).toString();
const crossUnsub = await verifyPushUnsubscribeSignature(
stubBlurt,
{
account: fixedAccount,
endpoint: fixedEndpoint,
timestamp: fixedTimestamp,
signatureHex: subSig
},
fixedTimestamp + 1
);
record(
'cp131 MED-009 — subscribe signature CANNOT be replayed as unsubscribe',
crossUnsub.ok === false && crossUnsub.reason === 'signature_mismatch'
);
// Scenario: symmetric — an UNSUBSCRIBE-action signature MUST
// NOT be accepted as a subscribe (cross-action replay defense).
const crossSub = await verifyPushSubscribeSignature(
stubBlurt,
{
account: fixedAccount,
endpoint: fixedEndpoint,
timestamp: fixedTimestamp,
signatureHex: unsubSig
},
fixedTimestamp + 1
);
record(
'cp131 MED-009 — unsubscribe signature CANNOT be replayed as subscribe',
crossSub.ok === false && crossSub.reason === 'signature_mismatch'
);
// Scenario: skew enforcement applies symmetrically to
// unsubscribe.
const unsubStale = await verifyPushUnsubscribeSignature(
stubBlurt,
{
account: fixedAccount,
endpoint: fixedEndpoint,
timestamp: fixedTimestamp,
signatureHex: unsubSig
},
fixedTimestamp + 6 * 60 // 6 min in the future
);
record(
'cp131 MED-009 — unsubscribe verifier rejects signatures beyond ±5min skew',
unsubStale.ok === false && unsubStale.reason === 'timestamp_out_of_range'
);
// Scenario: client + server agree on the unsubscribe canonical
// message string (parallel to the subscribe cross-check above).
const subtle = webcrypto.subtle;
const clientUnsubEndpointHashBuf = await subtle.digest(
'SHA-256',
new TextEncoder().encode(fixedEndpoint)
);
const clientUnsubEndpointHashHex = Array.from(
new Uint8Array(clientUnsubEndpointHashBuf)
)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
const clientUnsubCanonical = `morphit:push:unsubscribe:${fixedAccount}:${clientUnsubEndpointHashHex}:${fixedTimestamp}`;
record(
'cp131 MED-009 — client + server compute identical unsubscribe canonical',
clientUnsubCanonical === unsubCanonical,
clientUnsubCanonical !== unsubCanonical
? `server="${unsubCanonical}", client="${clientUnsubCanonical}"`
: undefined
);
}
// ─── Report ───────────────────────────────────────────────
console.log(`canonical-message-cross-check smoke: ${results.length} scenarios\n`);
let failed = 0;
for (const r of results) {
if (r.ok) {
console.log(`${r.name}`);
} else {
console.log(`${r.name}`);
if (r.detail) console.log(` ${r.detail}`);
failed++;
}
}
console.log('');
if (failed === 0) {
console.log(`✓ all ${results.length} canonical-message-cross-check scenarios pass`);
process.exit(0);
} else {
console.error(`${failed} cross-check failures`);
process.exit(1);
}