morphit/apps/web/scripts/chain-op-verify-smoke.ts

308 lines
11 KiB
TypeScript

/**
* Morphit web — local chain-op signature verification smoke
* (S14, Audit Part 26).
*
* Exercises the pure verifyTransactionSignatures helper with
* fixture transactions and authorities. The wrapper that
* fetches via RPC is integration-tested manually against a
* live Blurt endpoint; this smoke covers the cryptographic
* core.
*
* Scenarios:
*
* 1. Single-sig posting authority, signature from the
* authority's key → ok:true, weightSum=1, threshold=1.
* 2. Single-sig posting authority, signature from an
* UNRELATED key → weight_below_threshold.
* 3. Tx with empty signatures array → no_signatures.
* 4. Multi-sig (two keys, threshold=2): one matching
* signature → weight_below_threshold.
* 5. Multi-sig (two keys, threshold=2): two matching
* signatures → ok:true, weightSum=2.
* 6. Hostile-RPC simulation: same digest, signature
* tampered (one byte flipped in the recovery byte)
* → recovered to a non-authority key → fails.
* 7. PublicKey-typed key_auth entry (not just strings)
* → key lookup still works (covers the
* `typeof keyOrString === 'string'` branch).
* 8. Weight threshold of 0 (degenerate but legal in
* Graphene) — any signature passes vacuously.
*
* Usage:
* tsx apps/web/scripts/chain-op-verify-smoke.ts
*/
import {
Client,
cryptoUtils,
PrivateKey,
PublicKey,
type AuthorityType,
type SignedTransaction,
type Transaction
} from '@beblurt/dblurt';
import { Buffer } from 'buffer';
import { verifyTransactionSignatures } from '../src/lib/chat/chainOpVerifyCore.ts';
let failures = 0;
let scenarios = 0;
function scenario(name: string, fn: () => void | Promise<void>): Promise<void> {
scenarios++;
return Promise.resolve()
.then(fn)
.then(
() => {
console.log(`${name}`);
},
(err) => {
failures++;
console.log(`${name}`);
console.log(` ${err instanceof Error ? err.message : String(err)}`);
}
);
}
function assert(cond: unknown, msg: string): asserts cond {
if (!cond) throw new Error(msg);
}
// ─── fixtures ────────────────────────────────────────────────
/** Build a deterministic test PrivateKey from a seed.
* The PrivateKey constructor expects a 32-byte Buffer with
* a leading 0x80 prefix byte... actually no, PrivateKey takes
* a 32-byte scalar. We use fromSeed which derives from a
* string — clean and deterministic. */
function makeKeyFromSeed(seed: string): { priv: PrivateKey; pub: string } {
const priv = PrivateKey.fromSeed(seed);
const pub = priv.createPublic('BLT').toString();
return { priv, pub };
}
/** Build a minimal unsigned Transaction shape acceptable to
* cryptoUtils.transactionDigest. We use a custom_json op
* payload because it's the simplest serializable Operation. */
function buildUnsignedTx(): Transaction {
return {
ref_block_num: 12345,
ref_block_prefix: 67890,
expiration: '2026-05-04T00:00:00',
operations: [
[
'custom_json',
{
required_auths: [],
required_posting_auths: ['alice'],
id: 'morphit_chat_identity_v1',
json: '{"v":1,"chat_pub":"AAAA...","ts":1714694400}'
}
]
],
extensions: []
};
}
/** Sign the tx with the given private key and return a
* SignedTransaction shape (with the synthetic chain metadata
* fields the verifier doesn't actually read). */
function signTx(tx: Transaction, key: PrivateKey): SignedTransaction {
const c = Client as unknown as { DEFAULT_CHAIN_ID: Buffer };
const signed = cryptoUtils.signTransaction(tx, [key], c.DEFAULT_CHAIN_ID);
return {
...signed,
transaction_id: 'fixture0000000000000000000000000000000000',
block_num: 1,
transaction_num: 0
};
}
/** Multi-key signer: produces a tx with two signatures from
* two different keys. Uses signTransaction twice and merges
* the signatures arrays. */
function signTxMulti(tx: Transaction, keys: PrivateKey[]): SignedTransaction {
const c = Client as unknown as { DEFAULT_CHAIN_ID: Buffer };
let signed = cryptoUtils.signTransaction(tx, [keys[0]!], c.DEFAULT_CHAIN_ID);
for (let i = 1; i < keys.length; i++) {
signed = cryptoUtils.signTransaction(signed, [keys[i]!], c.DEFAULT_CHAIN_ID);
}
return {
...signed,
transaction_id: 'fixture0000000000000000000000000000000000',
block_num: 1,
transaction_num: 0
};
}
// ─── tests ───────────────────────────────────────────────────
async function run(): Promise<void> {
console.log('chain-op-verify smoke');
const aliceKey = makeKeyFromSeed('alice posting key seed for smoke');
const bobKey = makeKeyFromSeed('bob posting key seed for smoke');
const carolKey = makeKeyFromSeed('carol unrelated key seed for smoke');
// ─── Scenario 1 ───────────────────────────────────────────
await scenario('single-sig: matching signature → ok', async () => {
const tx = buildUnsignedTx();
const signed = signTx(tx, aliceKey.priv);
const auth: AuthorityType = {
weight_threshold: 1,
account_auths: [],
key_auths: [[aliceKey.pub, 1]]
};
const result = await verifyTransactionSignatures(signed, auth);
assert(result.ok === true, `expected ok=true, got ${JSON.stringify(result)}`);
assert(result.ok === true && result.weightSum === 1, `expected weightSum=1`);
assert(result.ok === true && result.threshold === 1, `expected threshold=1`);
});
// ─── Scenario 2 ───────────────────────────────────────────
await scenario('single-sig: unrelated signature → weight_below_threshold', async () => {
const tx = buildUnsignedTx();
const signed = signTx(tx, carolKey.priv); // signed by carol
const auth: AuthorityType = {
weight_threshold: 1,
account_auths: [],
key_auths: [[aliceKey.pub, 1]] // expects alice
};
const result = await verifyTransactionSignatures(signed, auth);
assert(result.ok === false, `expected ok=false`);
assert(
!result.ok && result.code === 'weight_below_threshold',
`expected weight_below_threshold, got ${result.ok ? 'ok' : result.code}`
);
});
// ─── Scenario 3 ───────────────────────────────────────────
await scenario('empty signatures array → no_signatures', async () => {
const signed: SignedTransaction = {
...buildUnsignedTx(),
signatures: [],
transaction_id: 'x',
block_num: 1,
transaction_num: 0
};
const auth: AuthorityType = {
weight_threshold: 1,
account_auths: [],
key_auths: [[aliceKey.pub, 1]]
};
const result = await verifyTransactionSignatures(signed, auth);
assert(
!result.ok && result.code === 'no_signatures',
`expected no_signatures, got ${result.ok ? 'ok' : result.code}`
);
});
// ─── Scenario 4 ───────────────────────────────────────────
await scenario('multi-sig: only one match (sum<threshold) → weight_below_threshold', async () => {
const tx = buildUnsignedTx();
const signed = signTx(tx, aliceKey.priv); // alice only
const auth: AuthorityType = {
weight_threshold: 2,
account_auths: [],
key_auths: [
[aliceKey.pub, 1],
[bobKey.pub, 1]
]
};
const result = await verifyTransactionSignatures(signed, auth);
assert(
!result.ok && result.code === 'weight_below_threshold',
`expected weight_below_threshold for 1<2, got ${result.ok ? 'ok' : result.code}`
);
});
// ─── Scenario 5 ───────────────────────────────────────────
await scenario('multi-sig: both match (sum=threshold) → ok', async () => {
const tx = buildUnsignedTx();
const signed = signTxMulti(tx, [aliceKey.priv, bobKey.priv]);
const auth: AuthorityType = {
weight_threshold: 2,
account_auths: [],
key_auths: [
[aliceKey.pub, 1],
[bobKey.pub, 1]
]
};
const result = await verifyTransactionSignatures(signed, auth);
assert(result.ok === true, `expected ok=true, got ${JSON.stringify(result)}`);
assert(result.ok === true && result.weightSum === 2, `expected weightSum=2`);
});
// ─── Scenario 6 ───────────────────────────────────────────
await scenario('tampered signature: recovers to non-authority key → fails', async () => {
const tx = buildUnsignedTx();
const signed = signTx(tx, aliceKey.priv);
// Tamper with the signature: flip the recovery byte (first byte
// of the hex) which changes the recovered pubkey. Even tiny
// edits to a valid signature recover to a totally different
// candidate pubkey (this is how secp256k1 recovery works).
const tampered = signed.signatures[0]!;
const flipped = (parseInt(tampered.slice(0, 2), 16) ^ 0x01).toString(16).padStart(2, '0');
const tamperedSig = flipped + tampered.slice(2);
const tamperedTx: SignedTransaction = {
...signed,
signatures: [tamperedSig]
};
const auth: AuthorityType = {
weight_threshold: 1,
account_auths: [],
key_auths: [[aliceKey.pub, 1]]
};
const result = await verifyTransactionSignatures(tamperedTx, auth);
// Expected: either weight_below_threshold (recovered key
// not in authority) or the malformed-sig-skip path also
// leading to weight=0. Either way, not ok.
assert(!result.ok, `expected failure on tampered sig, got ${JSON.stringify(result)}`);
});
// ─── Scenario 7 ───────────────────────────────────────────
await scenario('PublicKey-typed key_auths entry (not string) works', async () => {
const tx = buildUnsignedTx();
const signed = signTx(tx, aliceKey.priv);
// dblurt's AuthorityType permits PublicKey objects in
// key_auths (the chain-fetched account often deserializes
// these as PublicKey instances rather than strings). We
// support both forms.
const aliceKeyObj = PublicKey.fromString(aliceKey.pub);
const auth: AuthorityType = {
weight_threshold: 1,
account_auths: [],
key_auths: [[aliceKeyObj, 1]]
};
const result = await verifyTransactionSignatures(signed, auth);
assert(
result.ok === true,
`expected ok=true with PublicKey object, got ${JSON.stringify(result)}`
);
});
// ─── Scenario 8 ───────────────────────────────────────────
await scenario('weight_threshold=0 is degenerate but legal: any sig passes', async () => {
const tx = buildUnsignedTx();
const signed = signTx(tx, aliceKey.priv);
const auth: AuthorityType = {
weight_threshold: 0,
account_auths: [],
key_auths: [[aliceKey.pub, 1]]
};
const result = await verifyTransactionSignatures(signed, auth);
assert(result.ok === true, `expected ok=true with threshold=0`);
// weight_sum still 1 (sig matches alice)
assert(result.ok === true && result.weightSum === 1, `expected weightSum=1`);
});
console.log(`\n${'─'.repeat(60)}`);
if (failures === 0) {
console.log(`✓ all ${scenarios} scenarios passed`);
process.exit(0);
} else {
console.log(`${failures}/${scenarios} scenarios failed`);
process.exit(1);
}
}
await run();