morphit/scripts/blurt-noble-signer-recovery-proof.ts

192 lines
7.9 KiB
TypeScript

#!/usr/bin/env tsx
/*
* blurt-noble-signer-recovery-proof — cp173 elliptic-migration feasibility proof.
*
* Proves the ONLY property the elliptic→@noble/secp256k1 signing migration
* actually requires: that a @noble/secp256k1-based Blurt signer produces
* signatures the chain will ACCEPT.
*
* WHY RECOVERY, NOT BYTE-EQUALITY. An earlier framing of this proof asserted
* byte-exact equivalence with @beblurt/dblurt's elliptic-based signer. That
* is the WRONG invariant. Graphene-lineage chains (Blurt / Steem / Hive)
* verify a signature by RECOVERING the public key from it and checking that
* key against the operation's required authority. They do NOT require the
* specific RFC-6979 deterministic signature any particular library happens to
* emit — any valid CANONICAL ECDSA signature (low-S, low-R) that recovers to
* an authorized key is accepted. So the migration's correctness question is
* "does a noble-produced signature recover to the signer's key?", which this
* proves by feeding noble signatures to dblurt's OWN parser + recovery and
* confirming the recovered key matches.
*
* Cross-check: each noble signature is ALSO re-verified under noble itself
* (round-trip), and we assert the canonical constraints hold (low-S, low-R,
* 65-byte wire [recovery+31]++r(32)++s(32)).
*
* SCOPE. This is a FEASIBILITY PROOF, not a shipped migration. It does NOT
* change any Morphit signing path (apps/web/src/lib/blurt/sign.ts still calls
* dblurt's broadcast.sign). It does NOT broadcast to the live chain — the
* sandbox has no chain access, so the FINAL cutover still requires one real
* Blurt broadcast to confirm end-to-end acceptance. See
* docs/adr/0046-elliptic-signing-migration.md for the full plan and the
* standing REVISIT item.
*
* Runtime note: dblurt loads in CI via its pure-JS elliptic fallback (the
* native secp256k1 addon is not required for signing/recovery), so this smoke
* runs anywhere tsx + node run.
*/
import * as secp from '@noble/secp256k1';
import { createHash, createHmac, randomBytes } from 'node:crypto';
import * as dblurt from '@beblurt/dblurt';
// dblurt ships as CJS; under tsx the exports may sit on the namespace or on
// `.default` depending on interop. Resolve defensively so this runs both ways.
const dblurtNs = (dblurt as Record<string, unknown>).default
? ((dblurt as Record<string, unknown>).default as Record<string, unknown>)
: (dblurt as unknown as Record<string, unknown>);
const PrivateKey = dblurtNs.PrivateKey as new (b: Buffer) => {
createPublic: () => { toString: () => string };
};
const Signature = dblurtNs.Signature as {
fromBuffer: (b: Buffer) => { recover: (m: Buffer) => { toString: () => string } };
};
const GREEN = '\x1b[32m';
const RED = '\x1b[31m';
const RESET = '\x1b[0m';
// RFC-6979 HMAC hook required by noble v2's synchronous sign().
secp.etc.hmacSha256Sync = (key: Uint8Array, ...msgs: Uint8Array[]) =>
createHmac('sha256', key).update(secp.etc.concatBytes(...msgs)).digest();
type Result = { name: string; ok: boolean; detail?: string };
const results: Result[] = [];
const pass = (name: string) => results.push({ name, ok: true });
const fail = (name: string, detail?: string) => results.push({ name, ok: false, detail });
/*
* The candidate noble Blurt signer. This is the primitive the migration would
* wire into sign.ts in place of dblurt's broadcast.sign. Canonical-signature
* discipline: re-derive with a bumped RFC-6979 extra-entropy counter until both
* r and s have a clear high bit (graphene's "low-R + low-S" canonical form),
* then emit the 65-byte wire format the chain parses.
*/
function blurtSignNoble(digest32: Uint8Array, priv: Uint8Array): Buffer {
for (let n = 0; n < 1000; n++) {
const opts: { lowS: boolean; extraEntropy?: Uint8Array } = { lowS: true };
if (n > 0) {
const e = Buffer.alloc(32, 0);
e.writeUInt32LE(n, 0);
opts.extraEntropy = e;
}
const sig = secp.sign(digest32, priv, opts);
// cp474 — @noble/secp256k1 v2's `Signature.toBytes()` takes NO arguments;
// the 'compact' we used to pass was silently ignored. It already returns the
// 64-byte compact (r || s) form (it's the inverse of `fromBytes`, and
// `toCompactRawBytes()` is its explicit alias), so behaviour is unchanged —
// but the call now says what it actually does.
const compact = sig.toBytes();
const r = compact.slice(0, 32);
const s = compact.slice(32, 64);
if ((r[0] & 0x80) !== 0 || (s[0] & 0x80) !== 0) continue; // not canonical (high-R/high-S) — retry
const wire = Buffer.alloc(65);
wire[0] = sig.recovery + 31; // graphene recovery byte: 27 + recid + 4 (compressed)
Buffer.from(r).copy(wire, 1);
Buffer.from(s).copy(wire, 33);
return wire;
}
throw new Error('no canonical signature in 1000 iterations (astronomically unlikely)');
}
function randPriv(): Uint8Array {
for (;;) {
const p = randomBytes(32);
try {
secp.getPublicKey(p, true);
return p;
} catch {
/* out of range — retry */
}
}
}
/* ---- Proof 1: noble signatures recover to the correct key under DBLURT ---- */
{
const N = 300;
let okCount = 0;
let bad = 0;
for (let i = 0; i < N; i++) {
const priv = randPriv();
const digest = createHash('sha256')
.update('recover-' + i + '-' + randomBytes(6).toString('hex'))
.digest();
const wire = blurtSignNoble(digest, priv);
// Parse with dblurt's own wire parser, recover with dblurt's own recovery.
const recovered = Signature.fromBuffer(wire).recover(Buffer.from(digest)).toString();
const expected = new PrivateKey(Buffer.from(priv)).createPublic().toString();
if (recovered === expected) okCount++;
else {
bad++;
if (bad <= 3) fail(`recover vector ${i}`, `recovered=${recovered} expected=${expected}`);
}
}
if (bad === 0) pass(`noble→dblurt recovery: ${okCount}/${N} recover to the correct key`);
else fail('noble→dblurt recovery', `${bad}/${N} did not recover to the signer key`);
}
/* ---- Proof 2: canonical-form constraints hold on noble output ---- */
{
const N = 100;
let okCount = 0;
for (let i = 0; i < N; i++) {
const priv = randPriv();
const digest = createHash('sha256').update('canon-' + i).digest();
const wire = blurtSignNoble(digest, priv);
const recByte = wire[0];
const r0 = wire[1];
const s0 = wire[33];
const lenOk = wire.length === 65;
const recOk = recByte >= 31 && recByte <= 34; // 27 + recid(0..3) + 4
const lowR = (r0 & 0x80) === 0;
const lowS = (s0 & 0x80) === 0;
if (lenOk && recOk && lowR && lowS) okCount++;
}
if (okCount === N) pass(`canonical form: all ${N} sigs are 65-byte, low-R, low-S, valid recovery byte`);
else fail('canonical form', `${N - okCount}/${N} violated canonical constraints`);
}
/* ---- Proof 3: noble signatures round-trip-verify under noble ---- */
{
const N = 50;
let okCount = 0;
for (let i = 0; i < N; i++) {
const priv = randPriv();
const pub = secp.getPublicKey(priv, true);
const digest = createHash('sha256').update('verify-' + i).digest();
const wire = blurtSignNoble(digest, priv);
const compact = new Uint8Array(64);
wire.copy(Buffer.from(compact.buffer), 0, 1, 65);
if (secp.verify(compact, digest, pub, { lowS: true })) okCount++;
}
if (okCount === N) pass(`round-trip: all ${N} noble sigs verify under noble`);
else fail('round-trip verify', `${N - okCount}/${N} failed to verify`);
}
/* ---- report ---- */
let failed = 0;
for (const r of results) {
if (r.ok) {
console.log(` ${GREEN}${RESET} ${r.name}`);
} else {
console.log(` ${RED}${RESET} ${r.name}`);
if (r.detail) console.log(` ${r.detail}`);
failed++;
}
}
console.log('──────────────────────────────────────────────────────');
if (failed > 0) {
console.log(`${failed} of ${results.length} blurt-noble-signer-recovery-proof scenarios failed`);
process.exit(1);
} else {
console.log(`✓ all ${results.length} blurt-noble-signer-recovery-proof scenarios pass`);
}