pqc-migration-toolkit/raport.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

192 lines
9.7 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/*
* raport.js - produces an Aere Quantum Exposure Report from live on-chain measurement.
*
* WHY THIS FILE EXISTS AND WHAT MAKES IT WORTH PAYING FOR. Anyone can write a PDF that says
* "you are exposed to quantum risk". What a buyer cannot get elsewhere is a report that:
*
* - names, for every address, what was MEASURED and what was NOT, and never blurs the two;
* - carries the exact command that reproduces it, so the buyer can re-run it next quarter,
* on a node they run themselves, and get the same answer or a documented difference;
* - carries a manifest of digests so a changed report is detectable;
* - answers the questions a supervisor actually asks, in the wording of the published
* criteria, instead of inventing a scoring scheme.
*
* A report that cannot be reproduced is an opinion. This one is a measurement with a receipt.
*
* node raport.js 0xAddr [0xAddr...] --rpc https://... [--client "Name"] [--out dir]
* node raport.js --fixture fixtures/x.json --out dir # offline, for demos
*/
import { execFileSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { citesteVerdict } from './lib/bytecode.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const arg = (n, d) => { const i = argv.indexOf(n); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; };
const has = (n) => argv.includes(n);
const ADRESE = argv.filter((a) => /^0x[0-9a-fA-F]{40}$/.test(a));
const RPC = arg('--rpc', null);
const CLIENT = arg('--client', 'the holder of this report');
const OUT = arg('--out', 'raport-' + new Date().toISOString().slice(0, 10).replace(/-/g, ''));
const FIXTURE = arg('--fixture', null);
if (!ADRESE.length && !FIXTURE) {
console.error('folosire: node raport.js 0xAddr [0xAddr...] --rpc URL [--client "Nume"] [--out dir]');
process.exit(2);
}
const sha = (s) => crypto.createHash('sha256').update(s).digest('hex');
function scaneaza(adr) {
const a = ['scan.js', adr];
if (RPC) a.push('--rpc', RPC);
try {
const out = execFileSync(process.execPath, a, { cwd: __dirname, encoding: 'utf8', timeout: 120000 });
return { adr, ok: true, text: out.trim() };
} catch (e) {
return { adr, ok: false, text: String((e.stdout || '') + (e.stderr || e.message)).trim() };
}
}
// Reads the scan text back into the two things a buyer pays for: what was measured, and what the
// tool itself says it could not settle. The tool marks the latter with [VERIFY] or [MEASURE], and
// those lines are carried through verbatim rather than summarised away.
function citeste(text) {
// Verdictul se citeste prin functia comuna din lib/bytecode.js, NU printr-un tipar scris aici.
// Aici a fost defectul: acest fisier cauta AMBER, scanerul scrie YELLOW, deci fiecare contract
// hibrid, adica exact clientul care a inceput deja migrarea, cadea in NOT MEASURED.
const stare = citesteVerdict(text) || 'NOT MEASURED';
const precomp = [...text.matchAll(/^\s*-\s*(0x[0-9a-f]+)\s+(.+?)\s*(\[[a-z]+\])?$/gim)].map((m) => m[1] + ' ' + m[2].trim());
const nemasurat = [...text.matchAll(/^\s*\*\s*(\[(?:VERIFY|MEASURE)\][^\n]*)/gim)].map((m) => m[1].trim());
const cod = (text.match(/code:\s*(\d+)\s*bytes/) || [])[1] || null;
return { stare, precomp, nemasurat, cod };
}
const rezultate = ADRESE.map((a) => { const r = scaneaza(a); return { ...r, ...citeste(r.text) }; });
const acum = new Date().toISOString().replace('T', ' ').slice(0, 16) + 'Z';
const comanda = 'node raport.js ' + ADRESE.join(' ') + (RPC ? ' --rpc ' + RPC : '');
const nrRosu = rezultate.filter((r) => r.stare === 'RED').length;
const nrGalben = rezultate.filter((r) => r.stare === 'YELLOW').length;
const nrVerde = rezultate.filter((r) => r.stare === 'GREEN').length;
const nrNem = rezultate.filter((r) => r.stare === 'NOT MEASURED').length;
const totalNem = rezultate.reduce((s, r) => s + r.nemasurat.length, 0);
let md = `# Aere Quantum Exposure Report
**Prepared for:** ${CLIENT}
**Measured:** ${acum}
**Scope:** ${ADRESE.length} on-chain address${ADRESE.length === 1 ? '' : 'es'}${RPC ? ', read from ' + RPC : ', offline fixture'}
## Read this page first
This report states what was **measured** and what was **not**. Those are different, and mixing
them is how exposure reports become useless. Every line below is one or the other, and the
not-measured lines say why.
**It is reproducible.** Run this and compare:
${comanda}
Point \`--rpc\` at a node you run yourself. Nothing here depends on trusting the party that wrote
this report, and that is the point: an exposure report you cannot re-run is an opinion with a
logo on it.
## What was found
| address | verdict | code | live PQ verifiers reachable | open questions |
|---|---|---|---|---|
${rezultate.map((r) => `| \`${r.adr}\` | **${r.stare}** | ${r.cod ? r.cod + ' B' : 'n/a'} | ${r.precomp.length} | ${r.nemasurat.length} |`).join('\n')}
Totals: ${nrRosu} RED, ${nrGalben} YELLOW, ${nrVerde} GREEN, ${nrNem} not measured.
**${totalNem} question${totalNem === 1 ? '' : 's'} the tool refused to settle on its own** are listed per address below.
A tool that never says "I could not settle this" is not being careful, it is being quiet.
## Per address
${rezultate.map((r) => `### \`${r.adr}\` - ${r.stare}
${r.precomp.length ? 'Post-quantum verifiers reachable from this bytecode:\n' + r.precomp.map((p) => '- ' + p).join('\n') : 'No live post-quantum verifier was resolved from this bytecode.'}
${r.nemasurat.length ? '**Not settled by measurement, carried through verbatim:**\n' + r.nemasurat.map((n) => '- ' + n).join('\n') : '_Nothing was left unsettled for this address._'}
<details><summary>raw tool output</summary>
\`\`\`
${r.text}
\`\`\`
</details>
`).join('\n')}
## Answers to the questions a supervisor asks
These are written against **published criteria**, quoted as criteria and not as a certification.
This report is not an accredited audit and does not claim to be one.
**"Which of your on-chain components already depend on quantum-vulnerable signatures?"**
Every address in scope authorises through ECDSA secp256k1, because that is what an EVM account
is. The measured question is a different one: whether a component can *also* verify a
post-quantum signature, and ${rezultate.filter((r) => r.precomp.length).length} of ${ADRESE.length}
address${ADRESE.length === 1 ? '' : 'es'} in scope reach a live NIST post-quantum verifier.
**"Do you have a migration path that does not require redeploying everything?"**
Measured per address above. Where a component routes verification through a registry rather than
a hardcoded address, an algorithm can be added or retired without redeploying the component. That
property is visible in bytecode and is reported as such, not assumed.
**"What is the cost, and who pays it?"**
The toolkit's \`plan.js\` produces per-account gas figures from measured deployed-account costs.
Cost is quoted from measurement, never from a vendor estimate.
**"How would an auditor check your claim next year, without you?"**
By running the command at the top of this report against their own node. That is the entire
answer, and it is the reason this report has a command in it.
## What this report does NOT say
- It is **not** an accredited audit, and we are **not** an accredited audit firm. It measures the
on-chain slice: bytecode and what it can reach. Repositories, TLS, dependencies, key custody,
HSMs and off-chain services are **out of scope** and unmeasured here.
- It does **not** say "compliant with NIST". It says "measured against the criteria published in
NIST IR 8547". Compliance is a statement only an accredited body can make.
- It does **not** claim that a live post-quantum verifier makes an account quantum-safe. A
post-quantum verifier called from a transaction that is itself authorised with ECDSA gives no
post-quantum security: the adversary forges the outer transaction. Anyone selling you the
opposite can be taken apart in five minutes.
- It does **not** promise legal effect. What a timestamp or an attestation means in court depends
on jurisdiction and is not asserted here.
- "harvest now, decrypt later" does **not** apply to signatures: a signature is public and is not
harvested. The threat that applies to a chain is retroactive rewriting of history with keys
recovered later, which is a different and more serious problem.
## Integrity
Digests of every file in this report are in \`MANIFEST.sha256\`. Verify with:
sha256sum -c MANIFEST.sha256
`;
fs.mkdirSync(OUT, { recursive: true });
fs.writeFileSync(path.join(OUT, 'raport.md'), md);
fs.writeFileSync(path.join(OUT, 'raport.json'), JSON.stringify({
format: 'aere-quantum-exposure/1', client: CLIENT, measuredAt: acum, rpc: RPC || null,
command: comanda, addresses: rezultate.map((r) => ({ address: r.adr, verdict: r.stare, codeBytes: r.cod ? Number(r.cod) : null, pqVerifiers: r.precomp, notSettled: r.nemasurat })),
totals: { red: nrRosu, amber: nrGalben, green: nrVerde, notMeasured: nrNem, openQuestions: totalNem },
}, null, 2));
const fisiere = fs.readdirSync(OUT).filter((f) => f !== 'MANIFEST.sha256');
fs.writeFileSync(path.join(OUT, 'MANIFEST.sha256'),
fisiere.map((f) => sha(fs.readFileSync(path.join(OUT, f))) + ' ' + f).join('\n') + '\n');
console.log('Aere Quantum Exposure Report');
console.log(' adrese masurate : ' + ADRESE.length + ' RED ' + nrRosu + ', YELLOW ' + nrGalben + ', GREEN ' + nrVerde + ', NEMASURAT ' + nrNem);
console.log(' intrebari pe care unealta a refuzat sa le inchida singura: ' + totalNem);
console.log(' scris in : ' + OUT + '/ (raport.md, raport.json, MANIFEST.sha256)');
console.log(' reproductibil : ' + comanda);
process.exit(0);