278 lines
30 KiB
JavaScript
278 lines
30 KiB
JavaScript
#!/usr/bin/env node
|
|
// raport.mjs (quantum exposure report, C12): the command line that produces the QUANTUM EXPOSURE REPORT of a list of Bitcoin and
|
|
// Ethereum addresses, in one of three DECLARED modes, as a machine-readable JSON, a human-readable Markdown and an AIP-23
|
|
// `compliance` envelope whose evidenceHash binds the JSON (verifiable with the published reference verifier).
|
|
//
|
|
// mode 1 offline: by script / address type only. Reads no chain data, opens no connection. Cannot see spends.
|
|
// mode 2 with the client's OWN node or index, through one adapter: --electrum host:port | --esplora url | --bitcoind url
|
|
// [--txids file] | --eth-rpc url [--eth-blocks N|a..b]. Connections go only to that declared target.
|
|
// mode 3 with a THIRD-PARTY provider: the same adapters, only with --consimtamant-tert "<the written consent>"; the target must
|
|
// be a public service (a private or loopback target is refused) and the addresses are sent to it.
|
|
// Every address gets exactly one of two labels, "exposed (on-chain evidence)" or "no on-chain evidence of exposure", or the state
|
|
// "unmeasured" with a named reason; each verdict lists what was seen and what was not. Three questions about what no chain read here
|
|
// shows (shared extended public keys; signed messages, PSBTs, EIP-191/712 signatures; the same key on another chain) are asked and the
|
|
// answers are recorded as DECLARATIONS, never as measurements. The deadlines cited come from the compliance-report module beside the control plane, with
|
|
// the document, its version and the date it was read.
|
|
//
|
|
// node raport.mjs --mod 1|2|3 --adrese <file> [adapter flags] [--consimtamant-tert "text"] --iesire <dir>
|
|
// [--data YYYY-MM-DD] [--subiect <label, no personal data>] [--xpub-partajat da|nu|necunoscut]
|
|
// [--semnaturi-offchain da|nu|necunoscut] [--alte-lanturi da|nu|necunoscut] [--cnsa] [--json]
|
|
// <file>: one line per address: a Bitcoin address (base58check, bech32, bech32m), `script:<scriptPubKey hex>`,
|
|
// `p2pk:<public key hex>`, or an Ethereum address (0x + 40 hex; mixed case is checked as EIP-55). `#` starts a comment.
|
|
// --data: the day the report records (default: the UTC day of the run); a day after the UTC day of the run is refused, and with a day
|
|
// in the past no verdict rests on a block the target served after the end of that day (clasifica.mjs)
|
|
// Exit: 0 report written, every address classified; 2 report written, some addresses unmeasured; 1 refused (nothing written).
|
|
// Nothing about the owner is recorded: the addresses appear, nothing else. No value of any environment variable is printed.
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { ETICHETA, NEMASURAT, verificaLimbaj } from './limbaj.mjs';
|
|
// the cryptographic dependencies (package.json) are loaded first, through noble.mjs: when they are missing the tool names them and
|
|
// exits 2 ("could not run") instead of failing inside an import
|
|
try { await import('./noble.mjs'); } catch (e) { console.error(String((e && e.message) || e)); process.exit(2); }
|
|
const { decodeazaIntrareBitcoin } = await import('./bitcoin.mjs');
|
|
const { decodeazaAdresaEth } = await import('./ethereum.mjs');
|
|
const C = await import('./clasifica.mjs');
|
|
const { ADAPTOARE, Refuz, rezolvaPublic, tintaAdaptorului, tranzactiiCore, lantulCore } = await import('./adaptoare.mjs');
|
|
|
|
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
|
const primul = (lista) => lista.filter(Boolean).map((p) => path.resolve(AICI, p)).find((p) => fs.existsSync(p));
|
|
const TERMENE = primul([process.env.AERE_EXPUNERE_TERMENE, '../../../cloud-gateway/control-plane/raport-conformitate.mjs', '../../../aere-quantum/control-plane/raport-conformitate.mjs', '../control-plane/raport-conformitate.mjs']);
|
|
const PK = primul([process.env.AERE_EXPUNERE_PROOF_KINDS, '../../proof-kinds/proof-kinds.mjs', '../../../aere-quantum/proof-kinds/proof-kinds.mjs', '../proof-kinds/proof-kinds.mjs']);
|
|
if (!TERMENE || !PK) { console.error('raport.mjs: the compliance-report module (deadlines) or the proof-kinds builder is not beside this tool'); process.exit(1); }
|
|
const { REGIMURI, canonicJson, sha256Canonic } = await import(pathToFileURL(TERMENE).href);
|
|
const { buildProof } = await import(pathToFileURL(PK).href);
|
|
|
|
export const VERSIUNE = 'aere-quantum/exposure-report/1 (2026-10-12)';
|
|
export const INTREBARI = Object.freeze({
|
|
sharedXpub: 'Has an extended public key (xpub / ypub / zpub, or any account-level public key) of a wallet holding any of these addresses ever been given to a third party: a service, an exchange, a companion application, a co-signer, a backup? With such a key, once a quantum computer recovers one non-hardened child key, the parent private key and every sibling key follow (BIP-32).',
|
|
offChainSignatures: 'Have any of the keys behind these addresses signed anything off chain: Bitcoin signed messages (BIP-137, BIP-322), PSBTs or partially signed transactions sent to others, Ethereum EIP-191 or EIP-712 signatures (token permits, exchange orders, sign-in messages), or a transaction that was broadcast and never confirmed (replaced by fee, dropped from mempools)? Such a signature discloses the public key exactly as a confirmed spend would, and no chain shows it.',
|
|
otherChains: 'Has the same key, or the same address, signed on another chain: any other EVM chain where the same address is the same account (a layer 2, a sidechain, a test network), a Bitcoin fork that shares the history before its split, a Bitcoin test network, or a chain of another kind that uses the same secp256k1 key? A signature there discloses the public key exactly as one here would, and this report read only the chain named in each verdict.',
|
|
});
|
|
const RASPUNS = { da: 'yes (declared)', nu: 'no (declared)', necunoscut: 'unknown (declared)' };
|
|
|
|
// ---------------------------------------------------------------- arguments
|
|
function argumente(argv) {
|
|
const o = { _: [] };
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const t = argv[i];
|
|
const val = (k) => { if (i + 1 >= argv.length) throw new Refuz('usage', `${k} needs a value`); return argv[++i]; };
|
|
if (t === '--mod') o.mod = val(t); else if (t === '--adrese') o.adrese = val(t); else if (t === '--iesire') o.iesire = val(t);
|
|
else if (t === '--electrum') o.electrum = val(t); else if (t === '--esplora') o.esplora = val(t); else if (t === '--bitcoind') o.bitcoind = val(t); else if (t === '--eth-rpc') o['eth-rpc'] = val(t);
|
|
else if (t === '--eth-blocks') o.ethBlocks = val(t); else if (t === '--txids') o.txids = val(t);
|
|
else if (t === '--consimtamant-tert') o.consimtamant = val(t); else if (t === '--data') o.data = val(t); else if (t === '--subiect') o.subiect = val(t);
|
|
else if (t === '--xpub-partajat') o.xpub = val(t); else if (t === '--semnaturi-offchain') o.offchain = val(t); else if (t === '--alte-lanturi') o.alteLanturi = val(t);
|
|
else if (t === '--cnsa') o.cnsa = true; else if (t === '--json') o.json = true; else if (t === '--help' || t === '-h') o.help = true;
|
|
else throw new Refuz('usage', 'unknown argument ' + t);
|
|
}
|
|
return o;
|
|
}
|
|
const USAGE = `usage: node raport.mjs --mod 1|2|3 --adrese <file> --iesire <dir>
|
|
[--electrum host:port | --esplora <url> | --bitcoind <url> [--txids <file>] | --eth-rpc <url> [--eth-blocks N|a..b]]
|
|
[--consimtamant-tert "<written consent>"] [--data YYYY-MM-DD] [--subiect <label>] [--xpub-partajat da|nu|necunoscut]
|
|
[--semnaturi-offchain da|nu|necunoscut] [--alte-lanturi da|nu|necunoscut] [--cnsa] [--json]`;
|
|
|
|
function citesteAdrese(f) {
|
|
let text; try { text = fs.readFileSync(f, 'utf8'); } catch { throw new Refuz('input', 'the address file cannot be read'); }
|
|
const linii = text.split(/\r?\n/).map((l) => l.replace(/#.*$/, '').trim()).filter(Boolean);
|
|
if (!linii.length) throw new Refuz('input', 'the address file holds no address');
|
|
if (linii.length > 10000) throw new Refuz('input', 'more than 10000 addresses in one report');
|
|
const vazute = new Set(); const out = [];
|
|
for (const l of linii) { if (vazute.has(l)) continue; vazute.add(l); out.push(l); }
|
|
return out;
|
|
}
|
|
const esteEth = (l) => /^0x[0-9a-fA-F]{40}$/.test(l);
|
|
|
|
// ---------------------------------------------------------------- the report
|
|
export function construiesteRaport({ mode, date, subject, target, consent, declarations, cnsa, rezultate, adapterNotes }) {
|
|
const regimes = Object.fromEntries(Object.entries(REGIMURI).filter(([k]) => cnsa || k !== 'cnsa-2.0'));
|
|
const N = REGIMURI['nist-ir-8547'], U = REGIMURI['eu-2025'];
|
|
const summary = C.rezumat(rezultate, mode);
|
|
return {
|
|
v: 1, version: VERSIUNE, date, mode, subject,
|
|
modeDescription: mode === 1 ? 'offline: classification by script / address type only; no chain data read, no connection opened; spends are not visible'
|
|
: mode === 2 ? 'with the client\'s own node or index, through one adapter; connections only to the declared target'
|
|
: 'with a third-party provider, under the written consent recorded below; the addresses were sent to that provider',
|
|
target: target || null,
|
|
thirdPartyConsent: mode === 3 ? { given: true, text: consent } : { given: false, text: null },
|
|
declarations: {
|
|
sharedXpub: { question: INTREBARI.sharedXpub, answer: declarations.sharedXpub, nature: 'declaration by the requester, not a measurement; it does not change any label' },
|
|
offChainSignatures: { question: INTREBARI.offChainSignatures, answer: declarations.offChainSignatures, nature: 'declaration by the requester, not a measurement; it does not change any label' },
|
|
otherChains: { question: INTREBARI.otherChains, answer: declarations.otherChains, nature: 'declaration by the requester, not a measurement; it does not change any label' },
|
|
},
|
|
labels: { exposed: ETICHETA.EXPUSA, noEvidence: ETICHETA.FARA_DOVADA, unmeasured: NEMASURAT,
|
|
meaning: `"${ETICHETA.EXPUSA}": a public key (or the script holding the keys) of this address is readable from chain data this report names. "${ETICHETA.FARA_DOVADA}": nothing read in this mode shows the key; it says nothing about disclosure off chain, about data this mode cannot read, or about the future. "${NEMASURAT}": the measurement did not happen, for the reason written.` },
|
|
deadlines: {
|
|
appliesTo: 'the classical public-key algorithms behind every address in this report (ECDSA and Schnorr on secp256k1), whatever the label: the label says whether the key is already readable from the chain, the deadlines say until when the documents below allow such keys at all',
|
|
regimes,
|
|
summary: [
|
|
`${N.name} (${N.status}; document date ${N.sourceDate}; read ${N.read}): quantum-vulnerable public-key algorithms deprecated after ${N.deadlines.deprecated.slice(0, 4)}, disallowed after ${N.deadlines.disallowed.slice(0, 4)}`,
|
|
`${U.name} (document date ${U.sourceDate}; read ${U.read}; ${U.verified}): inventory and plans by ${U.deadlines.inventory}; high-risk use cases transitioned by ${U.deadlines.highRisk}; medium-risk by ${U.deadlines.mediumRisk}`,
|
|
...(cnsa ? [`${REGIMURI['cnsa-2.0'].name} (document date ${REGIMURI['cnsa-2.0'].sourceDate}; read ${REGIMURI['cnsa-2.0'].read}; ${REGIMURI['cnsa-2.0'].verified}): an optional profile for national security systems, cited as secondary`] : []),
|
|
],
|
|
},
|
|
summary, addresses: rezultate,
|
|
adapterNotes: adapterNotes || [],
|
|
limits: [
|
|
'The two labels describe only what the named chain data shows. A key disclosed off chain (a shared extended public key, a signed message, a PSBT, an EIP-191/712 signature) is not visible from any chain; the declarations above are the only place that can say so, and they are declarations.',
|
|
mode === 1 ? 'Mode 1 reads no chain data: a hash-type address that has already been spent from is reported as "no on-chain evidence of exposure" here, because the spend cannot be seen offline. Run mode 2 against your own node or index to see spends.' : 'The observations are those served by the declared target. A Bitcoin target is read for the chain it serves: its genesis block, which must be one of the address\'s network (a p2pk: or script: line is read on Bitcoin mainnet only), and, on Bitcoin mainnet, its block at the highest checkpoint height this tool holds at or below the end of its chain, which must be the checkpoint: a chain that split from Bitcoin after its genesis block (Bitcoin Cash, Bitcoin SV, eCash) shares that block and the proof-of-work limit, and serves another block there; a chain that split after the checkpoint used is not told apart, and one that ends below the lowest checkpoint is not measured. An Electrum or Esplora target is read for its tip too, whose header must meet its own proof of work and state a target within the proof-of-work limit of that chain (Bitcoin Core serves no tip header here). None of this shows the chain to be the one with the most work. An Electrum or Esplora verdict names the chain, and the height and the header time of the tip the target served before the history; "no on-chain evidence of exposure" is given only when that time is within ' + C.MARJA_VARF_ORE + ' hours of the end of the report date (or of the time of the run, when that comes first). An Ethereum verdict names the head block the endpoint served and its time, and the nonce and the code of the account are read only from an account proof (eth_getProof) at that head, every node of it checked by its hash from the state root of that block, the code hashed to the code hash proven; the head block itself is the endpoint\'s answer, not checked against the consensus of the chain. "no on-chain evidence of exposure" is given only when the endpoint does not report that it is syncing and that time is within ' + C.MARJA_CAP_ETH_MIN + ' minutes of the same instant. No verdict is given when what the target served is more than ' + C.MARJA_VARF_ORE + ' hours after that instant: its tip, its head, the block of a given transaction, or, for Bitcoin Core, whose scan reads the node\'s unspent set at the time of the run, the run itself. For a Bitcoin P2PKH or P2WPKH input the sibling scripts of the same HASH160 (P2PKH, P2WPKH, P2SH-P2WPKH) are read as well, and for a P2PK input the same key in each of its encodings under P2PK, P2PKH, P2WPKH and P2SH-P2WPKH and its BIP-86 P2TR output (through Electrum or Esplora from the target, through Bitcoin Core in the given transactions). It does verify, from the bytes served, that every key or script it names hashes to the address; that every Bitcoin transaction it reads hashes to its id (from its raw bytes, or re-serialized from the fields an index or a node serves; the id does not cover the witness, where a key or a script counts by its own hash equality with the address); and that every Ethereum transaction it cites hashes to the hash the endpoint gives, re-derived from the served fields.',
|
|
'This report does not protect, move or recover any coins; it does not say that the key of any address is undisclosed everywhere, and it says nothing about the future; it records what was readable on the date written above.',
|
|
'It is not a certification and not a legal opinion; the deadlines are those of the documents named, as read on the date written beside each; NIST IR 8547 was a draft when it was read.',
|
|
],
|
|
};
|
|
}
|
|
|
|
export function markdown(r, plic) {
|
|
const L = [];
|
|
const esc = (s) => String(s ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
|
|
L.push('# Quantum exposure report', '', `Date: ${r.date} `, `Mode: ${r.mode} (${r.modeDescription}) `, `Report format: ${r.version} `, `Subject: ${esc(r.subject)} `);
|
|
if (r.target) L.push(`Data source declared by the requester: ${esc(r.target.adapter)} at ${esc(r.target.host)}:${r.target.port} `);
|
|
if (r.thirdPartyConsent.given) L.push('', `Third-party provider used under this written consent: "${esc(r.thirdPartyConsent.text)}". The addresses in this report were sent to that provider.`);
|
|
L.push('', '## What this report is, and is not', '',
|
|
`Each address receives exactly one of two labels, or the state "${NEMASURAT}" with a reason.`, '',
|
|
`- **${ETICHETA.EXPUSA}**: a public key, or the script holding the keys, of the address is readable from chain data this report names (the evidence column says where).`,
|
|
`- **${ETICHETA.FARA_DOVADA}**: nothing read in this mode shows the key. It says nothing about disclosure off chain, about data this mode cannot read (the "not seen" column), or about the future.`, '',
|
|
'The report records what was readable on the date above. It does not protect, move or recover any coins.');
|
|
L.push('', '## Declarations (asked, not measured)', '');
|
|
for (const [k, d] of Object.entries(r.declarations)) L.push(`- ${d.question}`, ` Declared answer: **${esc(d.answer)}**. ${d.nature}.`, '');
|
|
L.push('## Classification', '', '| address | chain | type | classification | evidence seen | not seen |', '|---|---|---|---|---|---|');
|
|
for (const a of r.addresses) {
|
|
const tip = a.chain === 'bitcoin' ? `${a.scriptTypeName}${a.network && a.network !== 'unknown' ? ' (' + a.network + ')' : ''}` : a.checksum === 'valid' ? 'EIP-55 checksum valid' : a.checksum === 'none' ? 'no EIP-55 checksum (lowercase)' : '';
|
|
L.push(`| \`${esc(a.input)}\` | ${a.chain} | ${esc(tip)} | ${a.unmeasured ? `${NEMASURAT}: ${esc(a.unmeasured)}` : `**${esc(a.label)}**`} | ${a.evidence.map(esc).join('; ') || ''} | ${a.notSeen.map(esc).join('; ') || ''} |`);
|
|
}
|
|
L.push('', `Summary: ${esc(r.summary.verdict)}.`);
|
|
if (r.adapterNotes.length) { L.push('', '## Notes from the data source', ''); for (const n of r.adapterNotes) L.push(`- ${esc(n)}`); }
|
|
L.push('', '## Deadlines cited', '', r.deadlines.appliesTo + '.', '');
|
|
for (const s of r.deadlines.summary) L.push(`- ${s}`);
|
|
L.push('', 'Sources, as listed in the compliance-report module of the control plane:', '');
|
|
for (const [k, g] of Object.entries(r.deadlines.regimes)) L.push(`- ${k}: ${g.name}; ${g.source}; document date ${g.sourceDate}; read ${g.read}; verification: ${g.verified}${g.status ? '; status: ' + g.status : ''}`);
|
|
L.push('', '## Limits', '');
|
|
for (const l of r.limits) L.push(`- ${l}`);
|
|
L.push('', '## Verification', '',
|
|
`This report is bound to an AERE Proof Protocol (AIP-23) envelope of kind \`compliance\`: \`evidenceHash\` = SHA-256 of the canonical JSON of \`raport.json\` (keys sorted recursively, no spaces) = \`${plic.statement.evidenceHash}\`; \`statementHash\` = \`${plic.statementHash}\`.`,
|
|
'To check: recompute the canonical JSON hash of `raport.json` and compare it with `evidenceHash` in `plic-aip23.json`; then run the published AIP-23 reference verifier on `plic-aip23.json` (form and integrity levels; the finality level applies only once the envelope is notarized).', '');
|
|
return L.join('\n');
|
|
}
|
|
|
|
/** the report with every text that is not the tool's own taken out, for the language gate only (the written report keeps it all): the
|
|
* fields that hold the requester's text are emptied; inside any other string, the [served: ...] quotes of the target's text are emptied,
|
|
* an input line (the shortest is a 26-character address) is removed, and the target's host is removed where the adapters name it
|
|
* ("target <host>"), never as a bare substring (a short host would cut the tool's own words) */
|
|
export function mascheazaPentruPoarta(raport) {
|
|
const intrari = raport.addresses.map((a) => String(a.input || '')).filter((x) => x.length >= 8).sort((x, y) => y.length - x.length);
|
|
const gazda = raport.target && raport.target.host ? 'target ' + raport.target.host : null;
|
|
// a quote cut before its closing bracket (at the end of a string) is still the target's text
|
|
const curata = (s) => { let x = s.replace(/\[served: [^[\]]*(?:\]|$)/g, '[served: ]'); if (gazda) x = x.split(gazda).join('target '); for (const i of intrari) x = x.split(i).join(''); return x; };
|
|
const adanc = (v) => (typeof v === 'string' ? curata(v) : Array.isArray(v) ? v.map(adanc) : v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).map(([k, w]) => [k, adanc(w)])) : v);
|
|
const m = adanc(raport);
|
|
return { ...m, subject: '', target: m.target ? { ...m.target, host: '' } : m.target, thirdPartyConsent: { ...m.thirdPartyConsent, text: m.thirdPartyConsent.text === null ? null : '' }, addresses: m.addresses.map((a) => ({ ...a, input: '' })) };
|
|
}
|
|
|
|
// ---------------------------------------------------------------- main
|
|
async function main() {
|
|
const o = argumente(process.argv.slice(2));
|
|
if (o.help) { console.log(USAGE); return 0; }
|
|
const mode = Number(o.mod);
|
|
if (![1, 2, 3].includes(mode)) throw new Refuz('usage', '--mod must be 1, 2 or 3\n' + USAGE);
|
|
if (!o.adrese || !o.iesire) throw new Refuz('usage', '--adrese <file> and --iesire <dir> are required\n' + USAGE);
|
|
const azi = new Date().toISOString().slice(0, 10);
|
|
const date = o.data || azi;
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || new Date(date + 'T00:00:00Z').toISOString().slice(0, 10) !== date) throw new Refuz('usage', '--data must be a real calendar day, YYYY-MM-DD');
|
|
// a report records what was readable on its date: a day that has not come yet (after the UTC day of this run) is refused. A day in the
|
|
// past is accepted, and every verdict is then judged against the end of that day (clasifica.mjs: no label rests on a block after it)
|
|
if (date > azi) throw new Refuz('usage', `--data ${date} is after the day of this run (${azi}, UTC): a report records what was readable on its date, and that day has not come`);
|
|
for (const [k, v] of [['--xpub-partajat', o.xpub], ['--semnaturi-offchain', o.offchain], ['--alte-lanturi', o.alteLanturi]]) if (v !== undefined && !RASPUNS[v]) throw new Refuz('usage', `${k} must be da, nu or necunoscut`);
|
|
const declarations = { sharedXpub: o.xpub ? RASPUNS[o.xpub] : 'not answered (the question was asked)', offChainSignatures: o.offchain ? RASPUNS[o.offchain] : 'not answered (the question was asked)', otherChains: o.alteLanturi ? RASPUNS[o.alteLanturi] : 'not answered (the question was asked)' };
|
|
// the instant a target's tip is judged against (clasifica.mjs, MARJA_VARF_ORE): the end of the report's date, or the time of this run
|
|
// when that comes first (a report dated today is judged against the clock of the machine that runs it)
|
|
const sfarsitZi = Date.parse(date + 'T00:00:00Z') + 86400000;
|
|
const ctx = Date.now() < sfarsitZi ? { referinta: Date.now(), referintaText: 'the time this report was produced' } : { referinta: sfarsitZi, referintaText: `the end of the report date ${date} (${new Date(sfarsitZi).toISOString().replace('.000Z', 'Z')})` };
|
|
const feluri = ['electrum', 'esplora', 'bitcoind', 'eth-rpc'].filter((k) => o[k]);
|
|
if (mode === 1 && feluri.length) throw new Refuz('usage', 'mode 1 is offline: it accepts no data-source flag (--electrum, --esplora, --bitcoind, --eth-rpc)');
|
|
if (mode !== 1 && feluri.length !== 1) throw new Refuz('usage', `mode ${mode} needs exactly one data source: --electrum, --esplora, --bitcoind or --eth-rpc`);
|
|
if (mode === 3 && !(o.consimtamant && o.consimtamant.trim().length >= 8)) throw new Refuz('consent', 'mode 3 sends the addresses to a third-party provider; it runs only with --consimtamant-tert "<the written consent of the requester>" (at least 8 characters); without it the report is refused');
|
|
if (mode !== 3 && o.consimtamant) throw new Refuz('usage', '--consimtamant-tert belongs to mode 3 only');
|
|
const lines = citesteAdrese(o.adrese);
|
|
const fel = feluri[0] || null;
|
|
/*PLANTA:furnizor-strain*/
|
|
let target = null, lookup = undefined;
|
|
if (fel) {
|
|
const t = tintaAdaptorului(fel, o[fel]);
|
|
target = { adapter: fel, host: t.host, port: t.port };
|
|
if (mode === 3) { const r = await rezolvaPublic(t.host); lookup = r.lookup; target.resolvedTo = r.adresa.address; }
|
|
}
|
|
let txids = [];
|
|
if (o.txids) {
|
|
if (fel !== 'bitcoind') throw new Refuz('usage', '--txids belongs to --bitcoind');
|
|
let text; try { text = fs.readFileSync(o.txids, 'utf8'); } catch { throw new Refuz('input', 'the --txids file cannot be read'); }
|
|
// transaction ids are compared with the ids computed from the bytes (lowercase hex); an id in capitals is the same id
|
|
txids = [...new Set(text.split(/\r?\n/).map((l) => l.replace(/#.*$/, '').trim().toLowerCase()).filter(Boolean))];
|
|
if (txids.some((x) => !/^[0-9a-f]{64}$/.test(x))) throw new Refuz('input', 'every line of --txids must be a 64-hex transaction id');
|
|
}
|
|
const subject = o.subiect ? String(o.subiect).slice(0, 120) : 'addresses:' + crypto.createHash('sha256').update(lines.slice().sort().join('\n'), 'utf8').digest('hex').slice(0, 16);
|
|
|
|
const rezultate = []; const adapterNotes = []; const serverSeen = new Set();
|
|
// Bitcoin Core: the chain the node serves (its genesis block, its height, its block at a checkpoint height) is read ONCE and judged for
|
|
// every address (clasifica.mjs); a node whose chain cannot be read leaves every address unmeasured, with the reason
|
|
let lantCore = null, lantCoreError = null;
|
|
if (fel === 'bitcoind') {
|
|
try { const r = await lantulCore(o.bitcoind, { lookup }); if (r.partial) lantCoreError = r.partial; else { lantCore = r.chain; adapterNotes.push(`Bitcoin Core: the chain the node serves was read once (${r.requests} request(s): its genesis block, its height ${r.chain.height}${r.chain.control && r.chain.control.height !== undefined ? ', its block at the checkpoint height ' + r.chain.control.height : ''}) and judged for every address`); } }
|
|
catch (e) { if (e instanceof Refuz) throw e; lantCoreError = String(e.message); }
|
|
}
|
|
// Bitcoin Core: the given transactions are read ONCE and shared by every address; an id the node does not serve refuses the report
|
|
let cacheTxids = null, txidsError = null;
|
|
if (fel === 'bitcoind' && txids.length) {
|
|
try { const r = await tranzactiiCore(o.bitcoind, txids, { lookup }); cacheTxids = r.cache; adapterNotes.push(`Bitcoin Core: the ${txids.length} transaction id(s) given with --txids were read once (${r.requests} request(s)) and shared by every address`); }
|
|
catch (e) { if (e instanceof Refuz) throw e; txidsError = String(e.message); }
|
|
}
|
|
for (const line of lines) {
|
|
if (esteEth(line)) {
|
|
const a = decodeazaAdresaEth(line);
|
|
if (mode === 1) { rezultate.push(C.clasificaEthOffline(a)); continue; }
|
|
if (a.error) { rezultate.push(C.clasificaEthObservat(a, null, mode)); continue; }
|
|
if (fel !== 'eth-rpc') { rezultate.push({ input: line, chain: 'ethereum', address: a.address, checksum: a.checksum, mode, evidence: [], notSeen: [], label: null, unmeasured: `not measured: an Ethereum address needs --eth-rpc, the data source given is ${fel}` }); continue; }
|
|
const obs = await ADAPTOARE['eth-rpc'](a, { target: o[fel], mode, lookup, ethBlocks: o.ethBlocks });
|
|
rezultate.push(C.clasificaEthObservat(a, obs, mode, ctx));
|
|
} else {
|
|
const t = decodeazaIntrareBitcoin(line);
|
|
if (mode === 1) { rezultate.push(C.clasificaBitcoinOffline(t)); continue; }
|
|
if (t.error) { rezultate.push(C.clasificaBitcoinObservat(t, null, mode)); continue; }
|
|
if (fel === 'eth-rpc') { rezultate.push({ input: line, chain: 'bitcoin', network: t.network, scriptType: t.type, scriptTypeName: C.numeTip(t.type), form: t.form, mode, evidence: [], notSeen: [], label: null, unmeasured: 'not measured: a Bitcoin address needs --electrum, --esplora or --bitcoind, the data source given is eth-rpc' }); continue; }
|
|
const obs = await ADAPTOARE[fel](t, { target: o[fel], mode, lookup, txids, cacheTxids, txidsError, lantCore, lantCoreError });
|
|
if (obs && obs.server && !serverSeen.has(obs.server)) { serverSeen.add(obs.server); adapterNotes.push(`Electrum server version string: ${obs.server}`); }
|
|
rezultate.push(C.clasificaBitcoinObservat(t, obs, mode, ctx));
|
|
}
|
|
}
|
|
const raport = construiesteRaport({ mode, date, subject, target, consent: mode === 3 ? o.consimtamant.trim() : null, declarations, cnsa: !!o.cnsa, rezultate, adapterNotes });
|
|
const evidenta = sha256Canonic(raport);
|
|
const plic = buildProof('compliance', { subject, policy: VERSIUNE, result: raport.summary.verdict, evidenceHash: evidenta, createdAt: date + 'T00:00:00Z' });
|
|
const jsonText = JSON.stringify(raport, null, 1) + '\n';
|
|
const md = markdown(raport, plic) + '\n';
|
|
// the language gate: a report that promises protection is not written. It judges the TOOL's text; what the requester gave verbatim
|
|
// (the subject label, the consent text, the input lines, the target's host) and what the TARGET served (an error body or message, a
|
|
// version string, quoted by the adapters as [served: ...]) are recorded as given and are not the tool's promise
|
|
const mascat = mascheazaPentruPoarta(raport);
|
|
const hits = [...verificaLimbaj(JSON.stringify(mascat, null, 1)), ...verificaLimbaj(markdown(mascat, plic))];
|
|
if (hits.length) throw new Refuz('language', `the report would contain ${hits.length} forbidden word(s): ` + [...new Set(hits.map((h) => h.word))].join(', ') + ' (first at: "' + hits[0].context + '"); nothing was written');
|
|
fs.mkdirSync(o.iesire, { recursive: true });
|
|
fs.writeFileSync(path.join(o.iesire, 'raport.json'), jsonText);
|
|
fs.writeFileSync(path.join(o.iesire, 'raport.md'), md);
|
|
fs.writeFileSync(path.join(o.iesire, 'plic-aip23.json'), JSON.stringify(plic, null, 1) + '\n');
|
|
if (o.json) console.log(jsonText); else { console.log(raport.summary.verdict); for (const a of rezultate) console.log(` ${a.unmeasured ? NEMASURAT : a.label} ${a.input}${a.unmeasured ? ' ' + a.unmeasured : ''}`); console.log(`written: ${path.join(o.iesire, 'raport.json')}, raport.md, plic-aip23.json (evidenceHash ${evidenta.slice(0, 18)}..)`); }
|
|
return raport.summary.unmeasured ? 2 : 0;
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
main().then((c) => { process.exitCode = c; }, (e) => {
|
|
// every long hex run in a message is masked (a library may print a value it was given), except the strings a refusal names as
|
|
// the requester's own input (a transaction id from --txids): a refusal that cannot name the id it refuses is no use to the requester
|
|
const voie = new Set(e instanceof Refuz && Array.isArray(e.citate) ? e.citate : []);
|
|
const msg = String(e && e.message || e).replace(/[0-9a-fA-F]{40,}/g, (h) => (voie.has(h.toLowerCase()) ? h : '<hex>'));
|
|
// a refusal's message starts with its reason, which the prefix already names: written once
|
|
const corp = e instanceof Refuz && msg.startsWith(e.motiv + ': ') ? msg.slice(e.motiv.length + 2) : msg;
|
|
console.error((e instanceof Refuz ? 'REFUSED (' + e.motiv + '): ' : 'ERROR: ') + corp);
|
|
process.exitCode = 1;
|
|
});
|
|
}
|