#!/usr/bin/env node 'use strict'; /* * emite-certificat.mjs - turns a delivered report (or any file) into an anchoring request and, * once that request has been signed and mined, into a certificate the buyer can verify. * * IT NEVER SIGNS ANYTHING, AND THAT IS DELIBERATE. This tool produces an UNSIGNED transaction * plus the exact instructions to submit it. Signing is a separate act by whoever holds the key, * in a wallet they control. A notarization service that quietly holds a hot key and signs on your * behalf has made itself the thing you were supposed to be able to check. * * Two steps, and the split is the point: * * 1) node emite-certificat.mjs pregateste [--out dir] * Hashes the file, reads the current per-key nonce from the registry, and writes * cerere-ancorare.json: an unsigned EIP-1559 transaction object plus the challenge that the * post-quantum signature must cover. Nothing has touched a key. * * 2) node emite-certificat.mjs finalizeaza --tx 0x [--out dir] * After the transaction is mined, reads the receipt and writes certificat.json, the file the * buyer keeps. Then verifies it end to end by calling verifica-certificat.mjs, so a * certificate is never handed over unverified. * * The post-quantum signature itself is produced by the key holder with their Falcon or ML-DSA key, * over the challenge printed in step 1. This tool does not want that key and does not ask for it. */ import fs from 'node:fs'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; const REGISTRY = '0x465d9E3b476BF98Aa1393079e240Db5D2a9bEA6A'; const arg = (n, d) => { const i = process.argv.indexOf(n); return i > 0 && i + 1 < process.argv.length ? process.argv[i + 1] : d; }; const RPC = arg('--rpc', 'https://rpc.aere.network'); const OUT = arg('--out', '.'); const CMD = process.argv[2]; async function rpc(method, params) { const r = await fetch(RPC, { method: 'POST', headers: { 'content-type': 'application/json', connection: 'close' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(20000) }); if (!r.ok) throw new Error('HTTP ' + r.status); const j = await r.json(); if (j.error) throw new Error(JSON.stringify(j.error)); return j.result; } // keccak256, inlined (same implementation the verifier carries, so the two agree by construction) const RC = [1n,0x8082n,0x800000000000808an,0x8000000080008000n,0x808bn,0x80000001n,0x8000000080008081n, 0x8000000000008009n,0x8an,0x88n,0x80008009n,0x8000000an,0x8000808bn,0x800000000000008bn,0x8000000000008089n, 0x8000000000008003n,0x8000000000008002n,0x8000000000000080n,0x800an,0x800000008000000an,0x8000000080008081n, 0x8000000000008080n,0x80000001n,0x8000000080008008n]; const RR = [0,1,62,28,27,36,44,6,55,20,3,10,43,25,39,41,45,15,21,8,18,2,61,56,14]; const M = (1n << 64n) - 1n; const rotl = (x, n) => n === 0 ? x : ((x << BigInt(n)) | (x >> BigInt(64 - n))) & M; function keccakF(A) { for (let r = 0; r < 24; r++) { const C = new Array(5); for (let x = 0; x < 5; x++) C[x] = A[x] ^ A[x + 5] ^ A[x + 10] ^ A[x + 15] ^ A[x + 20]; for (let x = 0; x < 5; x++) { const D = C[(x + 4) % 5] ^ rotl(C[(x + 1) % 5], 1); for (let y = 0; y < 25; y += 5) A[x + y] ^= D; } const B = new Array(25); for (let x = 0; x < 5; x++) for (let y = 0; y < 5; y++) B[y + 5 * ((2 * x + 3 * y) % 5)] = rotl(A[x + 5 * y], RR[x + 5 * y]); for (let x = 0; x < 5; x++) for (let y = 0; y < 5; y++) A[x + 5 * y] = B[x + 5 * y] ^ ((~B[(x + 1) % 5 + 5 * y] & M) & B[(x + 2) % 5 + 5 * y]); A[0] ^= RC[r]; } return A; } function keccak256(bytes) { const rate = 136; const pad = new Uint8Array(rate - (bytes.length % rate)); pad[0] = 0x01; pad[pad.length - 1] |= 0x80; const msg = new Uint8Array(bytes.length + pad.length); msg.set(bytes); msg.set(pad, bytes.length); let A = new Array(25).fill(0n); for (let off = 0; off < msg.length; off += rate) { for (let i = 0; i < rate / 8; i++) { let w = 0n; for (let b = 7; b >= 0; b--) w = (w << 8n) | BigInt(msg[off + i * 8 + b]); A[i] ^= w; } A = keccakF(A); } const out = new Uint8Array(32); for (let i = 0; i < 4; i++) { let w = A[i]; for (let b = 0; b < 8; b++) { out[i * 8 + b] = Number(w & 0xffn); w >>= 8n; } } return out; } const hex = (u8) => '0x' + Array.from(u8).map((b) => b.toString(16).padStart(2, '0')).join(''); const sel = (sig) => hex(keccak256(new TextEncoder().encode(sig))).slice(0, 10); const pad32 = (v) => BigInt(v).toString(16).padStart(64, '0'); async function pregateste() { const fisier = process.argv[3]; if (!fisier || !fs.existsSync(fisier)) { console.error('folosire: node emite-certificat.mjs pregateste [--out dir] [--keyId N]'); process.exitCode = 2; return; } const keyId = Number(arg('--keyId', 0)); const amprenta = hex(keccak256(new Uint8Array(fs.readFileSync(fisier)))); // the per-key nonce the registry will use, read live: a certificate built against a stale nonce // would verify as false later, and that failure would look like a forgery instead of a mistake let nonce = null; try { const r = await rpc('eth_call', [{ to: REGISTRY, data: sel('nonceOf(uint256)') + pad32(keyId) }, 'latest']); if (r && r !== '0x') nonce = Number(BigInt(r)); } catch { /* left null on purpose, and said so below */ } // The challenge is ASKED OF THE CHAIN, never rebuilt here. The contract exposes // attestChallenge(keyId, nonce, messageHash), and a local reimplementation of that preimage // that differs by one byte yields a signature the chain rejects, which looks exactly like a // forgery instead of like our bug. let provocare = null; if (nonce !== null) { try { const r = await rpc('eth_call', [{ to: REGISTRY, data: sel('attestChallenge(uint256,uint64,bytes32)') + pad32(keyId) + pad32(nonce) + amprenta.slice(2) }, 'latest']); if (r && r !== '0x' && !/^0x0*$/.test(r)) provocare = r; } catch { /* stays null, and the nemasurat field below says so */ } } const cerere = { format: 'aere-pq-anchor-request/1', fisier: path.basename(fisier), documentHash: amprenta, registry: REGISTRY, chainId: 2800, keyId, nonce, provocare_de_semnat_post_cuantic: provocare, tranzactie_NESEMNATA: { to: REGISTRY, value: '0x0', chainId: 2800, data_fara_semnatura: sel('attest(uint256,bytes32,bytes)') + pad32(keyId) + amprenta.slice(2), nota: 'The calldata above is incomplete on purpose: the ABI tail carrying the post-quantum signature is appended by whoever holds the key. This file never contains a key and never will.', }, pasii_urmatori: [ '1. Sign the challenge above with your Falcon or ML-DSA key. This tool does not want that key.', '2. Encode attest(keyId, documentHash, signature) with that signature and submit it from an account you control.', '3. Run: node emite-certificat.mjs finalizeaza --tx 0x', ], nemasurat: (nonce === null || provocare === null) ? ['The registry did not answer nonceOf(uint256) or attestChallenge(...), so the nonce and therefore the challenge could NOT be computed. Do not sign anything until this reads a number: a signature over the wrong challenge will be rejected on chain and will look like a forgery.'] : [], }; fs.mkdirSync(OUT, { recursive: true }); const cale = path.join(OUT, 'cerere-ancorare.json'); fs.writeFileSync(cale, JSON.stringify(cerere, null, 2)); console.log('cerere de ancorare pregatita, NIMIC nu a fost semnat'); console.log(' document : ' + path.basename(fisier)); console.log(' amprenta : ' + amprenta); console.log(' nonce : ' + (nonce === null ? 'NEMASURAT' : nonce)); console.log(' provocare: ' + (provocare === null ? 'NEMASURAT, nu semna nimic pana nu apare aici' : provocare)); console.log(' scris : ' + cale); if (nonce === null || provocare === null) process.exitCode = 2; return; } async function finalizeaza() { const caleCerere = process.argv[3]; const txHash = arg('--tx', null); if (!caleCerere || !fs.existsSync(caleCerere) || !txHash) { console.error('folosire: node emite-certificat.mjs finalizeaza --tx 0x [--out dir]'); process.exitCode = 2; return; } const c = JSON.parse(fs.readFileSync(caleCerere, 'utf8')); const rc = await rpc('eth_getTransactionReceipt', [txHash]); if (!rc) { console.error('tranzactia nu e minata inca sau nu exista pe acest lant'); process.exitCode = 2; return; } if (rc.status !== '0x1') { console.error('tranzactia a esuat pe lant (status ' + rc.status + '), nu emit certificat'); process.exitCode = 1; return; } const blk = await rpc('eth_getBlockByNumber', [rc.blockNumber, false]); const cert = { format: 'aere-pq-notarization/1', emis: new Date().toISOString().slice(0, 10), documentHash: c.documentHash, registry: c.registry, keyId: c.keyId, nonce: c.nonce, txHash, blockNumber: parseInt(rc.blockNumber, 16), blockHash: blk.hash, chainId: 2800, anchorBlock: 13014000, anchorInterval: 32, plainHeaderBytes: 634, cum_se_verifica: 'node verifica-certificat.mjs acest-fisier.json --fisier documentul-tau [--rpc nodul-tau]', }; fs.mkdirSync(OUT, { recursive: true }); const cale = path.join(OUT, 'certificat.json'); fs.writeFileSync(cale, JSON.stringify(cert, null, 2)); console.log('certificat scris: ' + cale); // A certificate is never handed over unverified: run the public verifier on it right now. console.log('\n--- verificare imediata, cu chiar unealta pe care o primeste cumparatorul ---'); try { const out = execFileSync(process.execPath, [path.join(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1')), 'verifica-certificat.mjs'), cale, '--rpc', RPC], { encoding: 'utf8', timeout: 120000 }); console.log(out.split('\n').slice(-8).join('\n')); } catch (e) { console.error('VERIFICAREA A ESUAT, certificatul NU se livreaza asa:\n' + String(e.stdout || e.message).split('\n').slice(-8).join('\n')); process.exitCode = 1; return; } } if (CMD === 'pregateste') await pregateste(); else if (CMD === 'finalizeaza') await finalizeaza(); else { console.error('folosire: node emite-certificat.mjs pregateste | finalizeaza --tx 0x'); process.exitCode = 2; }