pqc-migration-toolkit/lib/bytecode.js
Aere Network de9a79298c Aere PQC migration toolkit: measure an address quantum exposure from its bytecode
Zero dependencies, no account, no API. Every command takes --rpc, so it runs
against a node you operate and never needs ours. That is the point: an exposure
measurement you cannot re-run is an opinion with a logo on it.

Ships with two self-checks meant to be run by you, not just by us. The second,
proba-vocabular.mjs, exists because of a real defect found on 2026-08-16: the
scanner emits RED / YELLOW / GREEN, two downstream files each wrote their own
copy of that list, and both wrote AMBER, so a hybrid contract, exactly a client
who has already started migrating, was reported as unmeasurable. Both sides were
self-consistent, so nothing we had could see it. The vocabulary now has one
exported source, and the test walks the whole path with a negative control that
makes it able to fail.

The same scanner returns RED about our own contracts. See VERIFY-US.md.
2026-08-17 10:31:31 +03:00

258 lines
11 KiB
JavaScript

// bytecode.js, opcode-aware static analysis of EVM runtime bytecode for
// quantum exposure. Zero dependencies.
//
// Two techniques make this honest rather than a grep:
//
// 1. Opcode-aware decoding. A naive search for "610ae1" (PUSH2 0x0AE1) in the
// hex produces false positives, because those bytes can appear inside
// another PUSH's immediate data or straddle an instruction boundary. The
// decoder walks the instruction stream, skipping each PUSH's immediate, so a
// matched constant is a genuine PUSH of that value.
//
// 2. Local stack simulation to resolve CALL targets. The real question is not
// "does the constant 0x0AE1 appear" but "is a CALL/STATICCALL actually made
// TO that address". We simulate the stack per basic block (reset at
// JUMPDEST / after JUMP,JUMPI / after terminators) tracking constant PUSH
// values, and at every CALL-family opcode we read the address argument (the
// 2nd stack item). This distinguishes a precompile call to 0x0AE1 from the
// bare number 0x0AE5 used as a memory length near an unrelated staticcall
// (the false positive a proximity heuristic would hit).
//
// Fallback: the constants 0x0AE1..0x0AE4 (2785..2788) are not round numbers or
// common offsets, so a genuine opcode-aligned PUSH of one is a medium-confidence
// signal even when the CALL target is loaded from memory and cannot be resolved
// locally. The common literals 0x01, 0x100 and 0x0AE5 are NEVER used as a
// fallback signal; they only count when stack simulation resolves them as an
// actual CALL target.
import { CLASSICAL, PQC, knownAddressTable } from './precompiles.js';
const CALL_NAME = { 0xf1: 'CALL', 0xf2: 'CALLCODE', 0xf4: 'DELEGATECALL', 0xfa: 'STATICCALL' };
const CALL_OPS = new Set([0xf1, 0xf2, 0xf4, 0xfa]);
// pops before the address arg is consumed: CALL/CALLCODE take (gas,addr,value,..)
// STATICCALL/DELEGATECALL take (gas,addr,..); in all four addr is the 2nd item.
const CALL_ARITY = { 0xf1: 7, 0xf2: 7, 0xf4: 6, 0xfa: 6 };
const HALTS = new Set([0x00, 0xf3, 0xfd, 0xfe, 0xff]); // STOP RETURN REVERT INVALID SELFDESTRUCT
// (pops, pushes) for opcodes whose stack effect we model to keep alignment.
// PUSH/DUP/SWAP/CALL-family are handled specially and are not in this table.
const ARITY = {
0x01: [2, 1], 0x02: [2, 1], 0x03: [2, 1], 0x04: [2, 1], 0x05: [2, 1], 0x06: [2, 1], 0x07: [2, 1],
0x08: [3, 1], 0x09: [3, 1], 0x0a: [2, 1], 0x0b: [2, 1],
0x10: [2, 1], 0x11: [2, 1], 0x12: [2, 1], 0x13: [2, 1], 0x14: [2, 1], 0x15: [1, 1],
0x16: [2, 1], 0x17: [2, 1], 0x18: [2, 1], 0x19: [1, 1], 0x1a: [2, 1], 0x1b: [2, 1], 0x1c: [2, 1], 0x1d: [2, 1],
0x20: [2, 1],
0x30: [0, 1], 0x31: [1, 1], 0x32: [0, 1], 0x33: [0, 1], 0x34: [0, 1], 0x35: [1, 1], 0x36: [0, 1],
0x37: [3, 0], 0x38: [0, 1], 0x39: [3, 0], 0x3a: [0, 1], 0x3b: [1, 1], 0x3c: [4, 0], 0x3d: [0, 1],
0x3e: [3, 0], 0x3f: [1, 1],
0x40: [1, 1], 0x41: [0, 1], 0x42: [0, 1], 0x43: [0, 1], 0x44: [0, 1], 0x45: [0, 1], 0x46: [0, 1],
0x47: [0, 1], 0x48: [0, 1], 0x49: [1, 1], 0x4a: [0, 1],
0x50: [1, 0], 0x51: [1, 1], 0x52: [2, 0], 0x53: [2, 0], 0x54: [1, 1], 0x55: [2, 0],
0x56: [1, 0], 0x57: [2, 0], 0x58: [0, 1], 0x59: [0, 1], 0x5a: [0, 1], 0x5b: [0, 0],
0x5c: [1, 1], 0x5d: [2, 0], 0x5e: [3, 0],
0xa0: [2, 0], 0xa1: [3, 0], 0xa2: [4, 0], 0xa3: [5, 0], 0xa4: [6, 0],
0xf0: [3, 1], 0xf5: [4, 1],
};
/**
* Decode runtime bytecode into a flat instruction list.
* @param {Uint8Array} code
*/
export function decode(code) {
const ops = [];
let pc = 0;
let i = 0;
while (pc < code.length) {
const op = code[pc];
if (op >= 0x60 && op <= 0x7f) {
const n = op - 0x5f;
let val = 0n;
for (let k = 1; k <= n && pc + k < code.length; k++) val = (val << 8n) | BigInt(code[pc + k]);
ops.push({ i: i++, pc, op, push: val, pushLen: n });
pc += 1 + n;
} else {
ops.push({ i: i++, pc, op });
pc += 1;
}
}
return ops;
}
/**
* Resolve CALL-family targets via per-basic-block local stack simulation.
* @returns {Array<{op:number, name:string, pc:number, target:bigint|null}>}
*/
export function resolveCallTargets(ops) {
const calls = [];
let stack = []; // items: bigint (known) or null (unknown)
const pop = () => (stack.length ? stack.pop() : null);
for (const o of ops) {
const op = o.op;
if (op === 0x5b) { stack = []; continue; } // JUMPDEST starts a new block
if (op >= 0x60 && op <= 0x7f) { stack.push(o.push); continue; } // PUSHn (0x5f PUSH0 handled below)
if (op === 0x5f) { stack.push(0n); continue; } // PUSH0
if (op >= 0x80 && op <= 0x8f) { // DUPk
const k = op - 0x80 + 1;
stack.push(stack.length >= k ? stack[stack.length - k] : null);
continue;
}
if (op >= 0x90 && op <= 0x9f) { // SWAPk
const k = op - 0x90 + 1;
const idx = stack.length - 1 - k;
if (stack.length >= 1 && idx >= 0) {
const top = stack.length - 1;
const tmp = stack[top]; stack[top] = stack[idx]; stack[idx] = tmp;
}
continue;
}
if (CALL_OPS.has(op)) {
// address is the 2nd stack item from the top (below gas)
const target = stack.length >= 2 ? stack[stack.length - 2] : null;
calls.push({ op, name: CALL_NAME[op], pc: o.pc, target });
for (let k = 0; k < CALL_ARITY[op]; k++) pop();
stack.push(null); // success flag
if (op === 0x56 || op === 0x57) stack = [];
continue;
}
if (op === 0x56 || op === 0x57) { // JUMP / JUMPI
const arity = ARITY[op];
for (let k = 0; k < arity[0]; k++) pop();
stack = []; // next instruction is a new block
continue;
}
if (HALTS.has(op)) {
const a = ARITY[op];
if (a) for (let k = 0; k < a[0]; k++) pop();
stack = [];
continue;
}
const a = ARITY[op];
if (a) {
for (let k = 0; k < a[0]; k++) pop();
for (let k = 0; k < a[1]; k++) stack.push(null);
} else {
// unknown opcode: be conservative, drop the block alignment
stack = [];
}
}
return calls;
}
/**
* Analyze runtime bytecode for classical and post-quantum precompile usage.
* @param {Uint8Array} code runtime bytecode (empty => caller treats as EOA)
*/
// SINGURA SURSA a vocabularului de verdicte. Exportata anume, fiindca doua consumatoare si-au
// scris fiecare propria lista si una a scris AMBER acolo unde scanerul scrie YELLOW: un contract
// hibrid, adica exact clientul care a inceput deja migrarea si e cel mai probabil sa plateasca,
// cadea in NOT MEASURED atat in raportul platit cat si in scanul gratuit. Masurat 2026-08-16.
// Cine adauga un verdict il adauga AICI, si consumatoarele il primesc fara sa fie atinse.
export const VERDICTE = Object.freeze(['GREEN', 'YELLOW', 'RED']);
// Cum se citeste verdictul dintr-o iesire de text. Consumatoarele cheama ASTA, nu isi scriu
// propriul tipar: un tipar copiat e a doua sursa a aceluiasi adevar, si exact asta a fost
// defectul. Nu foloseste expresii regulate, ca sa nu existe nici macar un sir de escapat gresit.
// Intoarce numele verdictului, sau null daca textul nu poarta niciunul.
export function citesteVerdict(text) {
for (const linie of String(text).split(/\r?\n/)) {
const cuvant = linie.trim().split(/[^A-Z]+/).filter(Boolean)[0];
if (cuvant && VERDICTE.includes(cuvant)) return cuvant;
}
return null;
}
export function analyzeBytecode(code) {
const ops = decode(code);
const table = knownAddressTable();
const calls = resolveCallTargets(ops);
// Resolved CALL targets that land on a known band address (definitive).
const resolved = calls
.filter((c) => c.target !== null && table.has(c.target))
.map((c) => ({ ...table.get(c.target), address: '0x' + c.target.toString(16), callOpcode: c.name, pc: c.pc }));
const pqcResolved = resolved.filter((r) => r.band === 'pqc-live');
const pqcTestnetResolved = resolved.filter((r) => r.band === 'pqc-testnet');
const ecrecoverResolved = resolved.filter((r) => r.key === 'ecrecover');
const p256Resolved = resolved.filter((r) => r.key === 'p256');
// Fallback presence signal: an opcode-aligned PUSH of a SIGNATURE-verify PQC
// address 0x0AE1..0x0AE4 (not 0x0AE5, not the common literals). Medium
// confidence: the constant is genuinely present even if we could not resolve
// the CALL target locally (e.g. it was staged through memory).
const sigPqcAddrs = new Set([PQC.falcon512.address, PQC.falcon1024.address, PQC.mldsa44.address, PQC.slhdsa128s.address]);
const pqcConstPresent = [];
for (const o of ops) {
if (o.push !== undefined && sigPqcAddrs.has(o.push)) {
const d = table.get(o.push);
pqcConstPresent.push({ address: '0x' + o.push.toString(16), name: d.name, pc: o.pc });
}
}
const uniqByAddr = (arr) => {
const m = new Map();
for (const x of arr) if (!m.has(x.address)) m.set(x.address, x);
return [...m.values()];
};
const pqcResolvedU = uniqByAddr(pqcResolved);
const pqcConstU = uniqByAddr(pqcConstPresent).filter((c) => !pqcResolvedU.find((r) => r.address === c.address));
const pqcUsed = pqcResolvedU.length > 0 || pqcConstU.length > 0;
const classicalUsed = ecrecoverResolved.length > 0 || p256Resolved.length > 0;
let readiness;
if (pqcUsed && classicalUsed) readiness = 'YELLOW';
else if (pqcUsed) readiness = 'GREEN';
else readiness = 'RED';
const pqcLivePrecompiles = [
...pqcResolvedU.map((r) => ({ address: r.address, name: r.name, callProximate: true, confidence: 'high' })),
...pqcConstU.map((c) => ({ address: c.address, name: c.name, callProximate: false, confidence: 'medium' })),
];
return {
kind: 'contract',
readiness,
bytecodeLength: code.length,
signals: {
pqcLivePrecompiles,
pqcTestnetPrecompiles: uniqByAddr(pqcTestnetResolved).map((r) => ({ address: r.address, name: r.name })),
p256: p256Resolved.length > 0
? { address: '0x100', name: CLASSICAL.p256.name, count: p256Resolved.length, confidence: 'high' }
: null,
ecrecover: ecrecoverResolved.length > 0
? { address: '0x1', name: CLASSICAL.ecrecover.name, count: ecrecoverResolved.length, confidence: 'high' }
: null,
},
flags: buildFlags({ pqcResolvedU, pqcConstU, pqcTestnetResolved, pqcUsed }),
detail: {
callsAnalyzed: calls.length,
resolvedBandCalls: resolved.length,
},
};
}
function buildFlags({ pqcResolvedU, pqcConstU, pqcTestnetResolved, pqcUsed }) {
const flags = [];
if (pqcConstU.length > 0 && pqcResolvedU.length === 0) {
flags.push('[VERIFY] A live PQC precompile address (0x0AE1..0x0AE4) is pushed but no CALL to it was resolved by local stack analysis; the call target may be staged through memory. Confirm against source or a call trace.');
}
if (pqcTestnetResolved.length > 0) {
flags.push('[VERIFY] Call to a TESTNET-only PQC precompile (0x0AE6..0x0AE8); these are NOT live on mainnet 2800 and return empty there.');
}
if (!pqcUsed) {
flags.push('Bytecode scanning only sees NATIVE precompile calls (0x0AE1..0x0AE5). A contract that verifies Falcon via the SOLIDITY AereFalcon512Verifier (0x4E8e...D8fFC) delegates to a normal contract address and will read as RED here even though it is PQC-capable. Confirm the auth path against source.');
}
return flags;
}