Dated corrections (2026-10-28): SPEC.md, SECURITY.md, AUDITS.md and anchor/README.md state what an anchor certificate is bound by (its digest sits in the hashed part of the header, so a node that validates the header refuses a detached or replaced certificate, while the block hash does not change); tools/verify-proof.mjs and tools/verify-anchor.mjs are the same files as those served at aere.network/tools

This commit is contained in:
Aere Network 2026-10-28 12:46:28 +02:00
parent cdc523ea64
commit 521210c71b
7 changed files with 163 additions and 64 deletions

View File

@ -4,7 +4,7 @@
purpose, and it stays here until it is no longer true. Everything below describes what we have purpose, and it stays here until it is no longer true. Everything below describes what we have
verified ourselves, which is a weaker thing, and we do not want the difference blurred. verified ourselves, which is a weaker thing, and we do not want the difference blurred.
Last updated: 2026-09-01. Last updated: 2026-10-28 (the sentence on the block hash and the certificate corrected that day).
--- ---
@ -12,7 +12,7 @@ Last updated: 2026-09-01.
| scope | auditor | date | report | | scope | auditor | date | report |
|---|---|---|---| |---|---|---|---|
| — | none | — | — | | none | none | none | none |
There is nothing to put in this table yet. When there is, the full report goes here, including any There is nothing to put in this table yet. When there is, the full report goes here, including any
findings we chose not to fix and why. findings we chose not to fix and why.
@ -40,9 +40,11 @@ findings we chose not to fix and why.
The post-quantum anchor is designed so you do not have to take our word for it: from block The post-quantum anchor is designed so you do not have to take our word for it: from block
13,014,000 every 32nd block (every 128th from block 17,225,968 to block 20,746,736) carries a 13,014,000 every 32nd block (every 128th from block 17,225,968 to block 20,746,736) carries a
post-quantum certificate (Falcon-512, and from block 17,047,600 also SLH-DSA-SHA2-128s, at every post-quantum certificate (Falcon-512, and from block 17,047,600 also SLH-DSA-SHA2-128s, at every
128th block since block 20,746,736), and the block hash covers it. Since block 20,715,632 that 128th block since block 20,746,736), and the block hash covers its 32-byte digest: the certificate itself is not under
the hash, and a swapped or stripped one no longer matches the digest, so a node that validates refuses the anchor (SPEC.md
2.3; corrected 2026-10-28: this said the block hash covers the certificate). Since block 20,715,632 that
certificate requires at least seven valid seals of each scheme, as many as the quorum of the ten validators (six from block 14,961,456, six of nine validators then); below block 24,148,752 the seals proved those validators signed the block, not that it was decided (AIP-22 erratum, 2026-10-01), and from that block a certificate that carries the quorum of one round proves the decision. The verifier is certificate requires at least seven valid seals of each scheme, as many as the quorum of the ten validators (six from block 14,961,456, six of nine validators then); below block 24,148,752 the seals proved those validators signed the block, not that it was decided (AIP-22 erratum, 2026-10-01), and from that block a certificate that carries the quorum of one round proves the decision. The verifier is
public, the signatures are in the headers, and the check runs against any node — ours or your own. public, the signatures are in the headers, and the check runs against any node, ours or your own.
## Reporting a vulnerability ## Reporting a vulnerability

View File

@ -38,10 +38,10 @@ pointer is <https://aere.network/.well-known/security.txt>.
every 32nd block (every 128th from block 17,225,968 to block 20,746,736) carries a post-quantum every 32nd block (every 128th from block 17,225,968 to block 20,746,736) carries a post-quantum
certificate under the block hash (Falcon-512, and from block 17,047,600 also SLH-DSA-SHA2-128s, at certificate under the block hash (Falcon-512, and from block 17,047,600 also SLH-DSA-SHA2-128s, at
every 128th block since block 20,746,736), requiring at least seven valid seals of each scheme every 128th block since block 20,746,736), requiring at least seven valid seals of each scheme
since block 20,715,632, as many as the quorum of the ten validators (six from block 14,961,456, six of nine validators then); below block 24,148,752 the seals proved those validators signed the block, not that it was decided (AIP-22 erratum, 2026-10-01), and from that block a certificate that carries the quorum of one round proves the decision. Blocks between anchors rest on ECDSA. We correct since block 20,715,632, as many as the quorum of the ten validators (six from block 14,961,456, six of nine validators then); below block 24,148,752 the seals proved those validators signed the block, not that it was decided (AIP-22 erratum, 2026-10-01), and from that block a certificate that carries the quorum of one round proves the decision. Blocks between anchors carry no certificate of their own and are decided by QBFT, whose votes every validator attributes, since block 24,300,000 (AIP-22 stage 4), only through their verified post-quantum seals (headers are still admitted on their ECDSA commit seals until AIP-22 stage 5); between anchors a node that does not take part in consensus checks only their headers (this sentence corrected on 2026-10-28: it said that blocks between anchors rest on ECDSA). We correct
over-claims publicly when we find them, and we have done so before. over-claims publicly when we find them, and we have done so before.
## Fixes ## Fixes
When a report leads to a fix, we publish the finding, the fix, and the window during which the When a report leads to a fix, we publish the finding, the fix, and the window during which the
issue was live — including anything we chose not to fix, and why. issue was live, including anything we chose not to fix, and why.

View File

@ -580,8 +580,9 @@ Correction added 2026-10-07: the sentence above ("protected by ECDSA alone, perm
than what holds for a node with a reference point and, for a node without one, an overstatement: than what holds for a node with a reference point and, for a node without one, an overstatement:
ECDSA is exactly what this adversary holds. History below block 13,014,000 carries no certificates ECDSA is exactly what this adversary holds. History below block 13,014,000 carries no certificates
of its own and the anchor cannot reach backwards; what covers it is the hash chain. Every block hash of its own and the anchor cannot reach backwards; what covers it is the hash chain. Every block hash
covers its parent's hash, and an anchor's hash covers its certificate, whose seals sign the parent's covers its parent's hash, and an anchor's hash covers the 32-byte digest of its certificate (section
hash, so a rewrite at ANY height changes every hash above it. A node that holds, from outside the 2.3; this clause corrected on 2026-10-28: it said the hash covers the certificate), whose seals sign
the parent's hash, so a rewrite at ANY height changes every hash above it. A node that holds, from outside the
sync, the height and block hash of one recent honest anchor (a reference point) therefore refuses sync, the height and block hash of one recent honest anchor (a reference point) therefore refuses
every history that diverges at or below that anchor, since no such history reaches the reference every history that diverges at or below that anchor, since no such history reaches the reference
with the same hash; above the reference the bound of this section applies (at most 32 blocks, the with the same hash; above the reference the bound of this section applies (at most 32 blocks, the

View File

@ -85,8 +85,10 @@ section) (Note added 2026-10-26: from block 24,148,752, AIP-22 stage R, a certif
The threat it addresses is not "harvest now, decrypt later". A signature is public; there is nothing The threat it addresses is not "harvest now, decrypt later". A signature is public; there is nothing
to harvest. The threat is **retroactive rewriting**: validator keys recovered later can be used to to harvest. The threat is **retroactive rewriting**: validator keys recovered later can be used to
re-sign old blocks, and a chain whose history is authenticated only by ECDSA cannot distinguish the re-sign old blocks, and a chain whose history is authenticated only by ECDSA cannot distinguish the
rewrite from the original. Binding a post-quantum certificate into the hash of every anchored header rewrite from the original. Binding the 32-byte digest of a post-quantum certificate into the hash of
means a rewrite must also forge the post-quantum signatures. every anchored header, so that a node that validates refuses a certificate that does not match it,
means a rewrite must also forge the post-quantum signatures (corrected 2026-10-28: this said the
certificate itself is bound into the hash; it is its digest, SPEC.md 2.3).
The threat is not ours to claim. It is the long-range attack, and the peer-reviewed treatment is The threat is not ours to claim. It is the long-range attack, and the peer-reviewed treatment is
Azouvi, Danezis and Nikolaenko, "Winkle: Foiling Long-Range Attacks in Proof-of-Stake Systems" Azouvi, Danezis and Nikolaenko, "Winkle: Foiling Long-Range Attacks in Proof-of-Stake Systems"
@ -166,7 +168,7 @@ now pinned: change one byte of it and the digest no longer matches, and the head
Updated 2026-08-29. The first publication of this directory, on 2026-08-11, was staged by hand and Updated 2026-08-29. The first publication of this directory, on 2026-08-11, was staged by hand and
was never refreshed: by 29 August, 63 of the 78 files here had changed in the tree we actually run was never refreshed: by 29 August, 63 of the 78 files here had changed in the tree we actually run
and 50 files had never been published at all. That is fixed at the root rather than by one copy — and 50 files had never been published at all. That is fixed at the root rather than by one copy:
`aerenew/publish-bundle/stage-anchor.cjs` in the operator repository derives this directory from the authoritative overlay, refuses `aerenew/publish-bundle/stage-anchor.cjs` in the operator repository derives this directory from the authoritative overlay, refuses
to run if the two files this README tells you to read first are missing, deletes what we removed to run if the two files this README tells you to read first are missing, deletes what we removed
rather than letting the published set grow monotonically, and stops on the secret-scanner's verdict rather than letting the published set grow monotonically, and stops on the secret-scanner's verdict
@ -185,7 +187,7 @@ This matters more than the file list, so it is stated before it.
|---|---|---| |---|---|---|
| Anchor certificate under the block hash | yes | **yes**, since block 13,014,000 | | Anchor certificate under the block hash | yes | **yes**, since block 13,014,000 |
| Enforced minimum seals at an anchor height | yes | **yes**, 7 of 10 per scheme since 20,715,632 (6 from 14,961,456) | | Enforced minimum seals at an anchor height | yes | **yes**, 7 of 10 per scheme since 20,715,632 (6 from 14,961,456) |
| Legacy per-block Falcon rule (`aere.falcon.forkBlock`) | yes | **no** — retired at the anchor block | | Legacy per-block Falcon rule (`aere.falcon.forkBlock`) | yes | **no**, retired at the anchor block |
| Post-quantum seal on PREPARE, emission | yes | **yes**, since block 16,690,000 | | Post-quantum seal on PREPARE, emission | yes | **yes**, since block 16,690,000 |
| Post-quantum seal on PREPARE, enforcement | yes | **armed for block 17,700,000** (set 2026-09-05) | | Post-quantum seal on PREPARE, enforcement | yes | **armed for block 17,700,000** (set 2026-09-05) |
| Post-quantum seal on the PROPOSAL, emission | yes | **yes**, since block 16,620,000 | | Post-quantum seal on the PROPOSAL, emission | yes | **yes**, since block 16,620,000 |
@ -205,7 +207,7 @@ This matters more than the file list, so it is stated before it.
Everything not yet in force is **disarmed by absence or by height, never by a flag**: the properties that arm them Everything not yet in force is **disarmed by absence or by height, never by a flag**: the properties that arm them
are unset, and unset means never. Each refuses loudly on a value it cannot parse rather than booting are unset, and unset means never. Each refuses loudly on a value it cannot parse rather than booting
a node that believes itself armed — a node that disarms itself because of a mistyped character looks a node that believes itself armed: a node that disarms itself because of a mistyped character looks
exactly like a correctly configured one, right up to the day it matters. The tests for that exactly like a correctly configured one, right up to the day it matters. The tests for that
behaviour are in this directory and they are the ones to read if you doubt the claim. behaviour are in this directory and they are the ones to read if you doubt the claim.
@ -223,20 +225,20 @@ proposal without a valid Falcon seal from its own proposer does not open a round
its own domain over (chainId, height, round, digest), so an honest proposal seal cannot be replayed its own domain over (chainId, height, round, digest), so an honest proposal seal cannot be replayed
as a vote nor a vote seal as a proposal - both directions are tested. The ROUND-CHANGE message as a vote nor a vote seal as a proposal - both directions are tested. The ROUND-CHANGE message
carries its own seal since 2026-08-31 (its embedded justifications are coupled to PREPARE carries its own seal since 2026-08-31 (its embedded justifications are coupled to PREPARE
enforcement). What remains classical on chain 2800: node-level devp2p authentication, the ECDSA commit seals that every header still carries until AIP-22 stage 5, and the ECDSA commit seal that a commit carries besides its post-quantum seal. The validators are named in enforcement). What remains classical on chain 2800: node-level devp2p authentication, the ECDSA commit seals that every header still carries until AIP-22 stage 5, the ECDSA commit seal that a commit carries besides its post-quantum seal, and the ECDSA envelope that every consensus message still carries on the wire, whose form both clients judge but which, since block 24,300,000 (AIP-22 stage 4), no longer names its author (this sentence corrected on 2026-10-28: it did not name the envelope; AIP-22, note of 2026-10-27 under section 5). The validators are named in
consensus by the post-quantum ids of AIP-22 since block 20,836,228 (stage 3, 2026-09-30); that code is in this directory. consensus by the post-quantum ids of AIP-22 since block 20,836,228 (stage 3, 2026-09-30); that code is in this directory.
(Corrected 2026-10-22: until that date this sentence also listed validator identity as classical and said that the (Corrected 2026-10-22: until that date this sentence also listed validator identity as classical and said that the
post-quantum identity was unarmed on chain 2800 and ran only on testnet 28001.) post-quantum identity was unarmed on chain 2800 and ran only on testnet 28001.)
### The files ### The files
- `consensus/common/.../bft/` — the anchor itself: configuration, the digest, the seal cache and - `consensus/common/.../bft/`: the anchor itself: configuration, the digest, the seal cache and
store, the producer that attaches seals, the Falcon registry that maps a validator to a key. store, the producer that attaches seals, the Falcon registry that maps a validator to a key.
- `consensus/qbft/.../headervalidationrules/` — the validation rules: the digest must match, the - `consensus/qbft/.../headervalidationrules/`: the validation rules: the digest must match, the
seals must verify, and the rules must actually be wired into the validator chain. seals must verify, and the rules must actually be wired into the validator chain.
- `app/.../controller/` — where the rules are built and where the node refuses to start on a - `app/.../controller/`: where the rules are built and where the node refuses to start on a
configuration that would produce headers its own fleet rejects. configuration that would produce headers its own fleet rejects.
- tests — including the negative controls. A test that cannot fail is not a test, and several of - tests, including the negative controls. A test that cannot fail is not a test, and several of
these exist specifically to prove the guards can fail. these exist specifically to prove the guards can fail.
`BASE.txt` names the upstream commit. Applying these files to any other tree overwrites whatever `BASE.txt` names the upstream commit. Applying these files to any other tree overwrites whatever

View File

@ -60,18 +60,26 @@
// publish that ourselves), or that the RPC you use is honest about chain contents - bring your own // publish that ourselves), or that the RPC you use is honest about chain contents - bring your own
// node and pass RPC=<your-url> to remove even that. The manifests can also be given from disk. // node and pass RPC=<your-url> to remove even that. The manifests can also be given from disk.
// //
// Run it: // Run it (corrected 2026-10-28: this said `npm install @noble/hashes @noble/curves`, with no versions; npm installs the 2.x releases
// npm install @noble/hashes @noble/curves // today, which no longer export the subpaths this tool imports, so the tool stopped at its first import with exit 1 and an
// ERR_PACKAGE_PATH_NOT_EXPORTED line, before reading anything from the chain: that exit is not a verdict about the chain):
// npm install # with the package.json published beside this file (aere.network/tools/package.json)
// npm install @noble/hashes@1 @noble/curves@1 @noble/post-quantum@0.7 # or the same versions by hand
// node verify-anchor.mjs # 20 most recent anchors, manifests fetched from aere.network // node verify-anchor.mjs # 20 most recent anchors, manifests fetched from aere.network
// node verify-anchor.mjs 13921040 # 20 anchors ending at a chosen height // node verify-anchor.mjs 13921040 # 20 anchors ending at a chosen height (the on-chain pin is proven under the state of
// # that height, which needs an endpoint that keeps it, an archive node; on the public
// # endpoints the pin is UNMEASURED and the exit is 2, the anchors still judged)
// ANCHORS=100 node verify-anchor.mjs # more anchors // ANCHORS=100 node verify-anchor.mjs # more anchors
// MANIFESTS=./manifeste node verify-anchor.mjs # manifests from a local directory instead of the site // MANIFESTS=./manifeste node verify-anchor.mjs # manifests from a local directory instead of the site
// RPC=http://127.0.0.1:8545 node verify-anchor.mjs // RPC=http://127.0.0.1:8545 node verify-anchor.mjs
// ANCHOR_V2_BLOCK=17047600 node verify-anchor.mjs # the v2 height V (default: the chain's own, from the table; "none" = never) // ANCHOR_V2_BLOCK=17047600 node verify-anchor.mjs # the v2 height V (default: the chain's own, from the table; "none" = never)
// //
// Exit 0 only when every leg holds on every anchor checked. Any failure is the finding. Exit 2 (since 2026-10-27): UNMEASURED, a line // Exit 0 only when every leg holds on every anchor checked. Any failure is the finding (exit 1). Exit 2: UNMEASURED, a line that
// that starts with "UNMEASURED:" says why: the endpoint serves another chain than CHAIN_ID or does not say which, or the manifests do // starts with "UNMEASURED:" or "NOT VERIFIED" says why, and it is not a finding about the chain: an on-chain pin that cannot be
// not load or are of another chain; nothing about the chain was judged, and that is not a finding about it. // proven on this endpoint (one that does not keep the state of that block: a historical height needs an archive endpoint, and the
// anchors themselves can still have been judged OK), no anchor verified at all, or, since 2026-10-27, an endpoint of another chain
// than CHAIN_ID or one that does not say which, or manifests that do not load or are of another chain, and then nothing about the
// chain was judged (corrected 2026-10-28: this said exit 2 exists since 2026-10-27 and named only those last reasons).
import { keccak_256 } from '@noble/hashes/sha3'; import { keccak_256 } from '@noble/hashes/sha3';
import { sha256 } from '@noble/hashes/sha256'; import { sha256 } from '@noble/hashes/sha256';
@ -123,8 +131,10 @@ const COMMIT_DOMAIN_2 = 'AERE-PQ-COMMIT-2';
const ANCHOR_DOMAIN_V3 = 'AERE-PQ-ANCHOR-3'; const ANCHOR_DOMAIN_V3 = 'AERE-PQ-ANCHOR-3';
const MAX_ROUND = 1024; // PqAnchorV3.MAX_ROUND in both clients; no QBFT round timer gets there const MAX_ROUND = 1024; // PqAnchorV3.MAX_ROUND in both clients; no QBFT round timer gets there
const MAX_V3_ROWS = 64; const MAX_V3_ROWS = 64;
// H_r per chain: UNSET on both chains at the time of writing (no v3 anchor exists on any live chain). When the fleet publishes H_r // H_r per chain: set on both chains, testnet 28001 from block 6,157,024 and chain 2800 from block 24,148,752 (reached on 2026-10-24);
// for a chain it is written here, in the same hour, before H_r (D-372: the new verifier is published BEFORE the first v3 anchor). // when this line was first written neither was set and no v3 anchor existed on any live chain (corrected 2026-10-28: it still said
// UNSET). When the fleet sets H_r for a chain it is written here, in the same hour, before H_r (the new verifier is published BEFORE
// the first v3 anchor).
// COMMIT_ROUND_BLOCK overrides (a height, or "none"); cloud-gateway/orar-ancore.mjs carries the same value for the consumers. // COMMIT_ROUND_BLOCK overrides (a height, or "none"); cloud-gateway/orar-ancore.mjs carries the same value for the consumers.
const COMMIT_ROUND_DEFAULT = CHAIN_ID === 28001 ? 6157024 : 24148752; const COMMIT_ROUND_DEFAULT = CHAIN_ID === 28001 ? 6157024 : 24148752;
// 2026-10-27: the line above names 28001 and gives every OTHER id the H_r of 2800 (its form is read by the activation tools, so it stays // 2026-10-27: the line above names 28001 and gives every OTHER id the H_r of 2800 (its form is read by the activation tools, so it stays

View File

@ -19,10 +19,25 @@
// manifests); the header of the anchor's parent is re-encoded and must hash to the parent hash those seals signed; // manifests); the header of the anchor's parent is re-encoded and must hash to the parent hash those seals signed;
// that header's state root then anchors a Merkle proof (eth_getProof) that the AereNotary contract (its code hash // that header's state root then anchors a Merkle proof (eth_getProof) that the AereNotary contract (its code hash
// pinned below) holds this statementHash, and the time it was first seen. The endpoint is only a courier: a header, // pinned below) holds this statementHash, and the time it was first seen. The endpoint is only a courier: a header,
// a proof or a value it altered would not match the certified hash. // a proof or a value it altered would not match the certified hash. A courier of another chain (its eth_chainId is
// not the chain checked) or one that does not say which chain it serves is UNMEASURED, by name (since 2026-10-27;
// until then it made the anchor verifier accuse a healthy chain and this level FAILED)
// 5 witness (2026-10-18, the Proof line: a Bitcoin witness for every notarization) present when the envelope carries
// `bitcoinWitness` (written by tools/aere-quantum-shield/martor-notar-dovada.mjs), or when the file IS such a witness
// (format aere-notar-martor-1): the leaf (this statementHash as the chain recorded its notarization) hashes as recorded,
// its Merkle inclusion proof leads to the lot's root, the .ots bytes are an OpenTimestamps proof FOR that root, and the
// proof's restricted evaluation leads to the Merkle root of a Bitcoin block. Offline the level is UNMEASURED
// (ots-header-not-chain-checked: nothing dates anything without a header); PASSED needs --btc-antet <80-byte header of
// that block> or --live (two public explorers that must agree). What PASSED proves: the notarization existed no later
// than that Bitcoin block's time. The judge is martor-notar-verifica.mjs, looked for in AERE_MARTOR_VERIFICA, beside
// this file, then ../tools/aere-quantum-shield/; when it is not here the level is UNMEASURED and names `ots verify`.
// //
// Usage: node verify-proof.mjs <envelope.json> [--rpc <url>] [--chain 2800|28001] [--notary <address>] [--manifests <url|dir>] [--json] // Usage: node verify-proof.mjs <envelope.json> [--rpc <url>] [--chain 2800|28001] [--notary <address>] [--manifests <url|dir>]
// [--live] [--btc-antet <80-byte hex>] [--offline] [--json]
// Exit: 0 every level present passed; 1 a level present failed; 2 a level present could not be measured here (or is pending). // Exit: 0 every level present passed; 1 a level present failed; 2 a level present could not be measured here (or is pending).
// --offline (2026-10-18): nothing leaves this machine. Form, integrity and signature are judged as always; a notarization the
// envelope claims is reported UNMEASURED by name (never dropped, never passed); the Bitcoin witness is judged as far as its local
// judge can without a header. This is the mode the browser page runs (deploy/website/verify.html bundles judge() below).
// //
// The notary stores the block TIME of the first sighting (block.timestamp), not a block number. The block that holds the first // The notary stores the block TIME of the first sighting (block.timestamp), not a block number. The block that holds the first
// sighting is located through the endpoint's logs and reported as such (it is not part of what is certified). // sighting is located through the endpoint's logs and reported as such (it is not part of what is certified).
@ -59,7 +74,7 @@ export function rlpEncode(x) {
return Buffer.concat([encLen(b.length, 0x80), b]); return Buffer.concat([encLen(b.length, 0x80), b]);
} }
const scalar = (h) => { const n = BigInt(h); return n === 0n ? Buffer.alloc(0) : hexb(n.toString(16)); }; const scalar = (h) => { const n = BigInt(h); return n === 0n ? Buffer.alloc(0) : hexb(n.toString(16)); };
/** the hash the chain gives a header: QBFT strips the committed seals and zeroes the round before hashing (CLAUDE.md s.2) */ /** the hash the chain gives a header: QBFT strips the committed seals and zeroes the round before hashing (aere-node SPEC.md 2.3) */
export function onchainHash(b) { export function onchainHash(b) {
const e = rlpDecode(hexb(b.extraData)); const e = rlpDecode(hexb(b.extraData));
const extra = rlpEncode([e[0], e[1], e[2], Buffer.alloc(0), []]); const extra = rlpEncode([e[0], e[1], e[2], Buffer.alloc(0), []]);
@ -80,7 +95,7 @@ export { rlpDecode, mptGet };
const level = (name, state, detail, extra) => ({ level: name, state, detail, ...(extra || {}) }); const level = (name, state, detail, extra) => ({ level: name, state, detail, ...(extra || {}) });
// ---------- levels 1 and 2 ---------- // ---------- levels 1 and 2 ----------
function local(env, notJson = null) { export function local(env, notJson = null) {
const out = []; const out = [];
const obj = (x) => !!x && typeof x === 'object' && !Array.isArray(x); const obj = (x) => !!x && typeof x === 'object' && !Array.isArray(x);
const formOk = obj(env) && obj(env.statement) const formOk = obj(env) && obj(env.statement)
@ -102,7 +117,7 @@ function local(env, notJson = null) {
/** does this Node.js verify ML-DSA at all (OpenSSL 3.5)? asked once, by making a key: the answer is the runtime's, not a version string's */ /** does this Node.js verify ML-DSA at all (OpenSSL 3.5)? asked once, by making a key: the answer is the runtime's, not a version string's */
let mlDsaHere = null; let mlDsaHere = null;
const canMlDsa = () => { if (mlDsaHere === null) { try { crypto.generateKeyPairSync('ml-dsa-44'); mlDsaHere = true; } catch { mlDsaHere = false; } } return mlDsaHere; }; const canMlDsa = () => { if (mlDsaHere === null) { try { crypto.generateKeyPairSync('ml-dsa-44'); mlDsaHere = true; } catch { mlDsaHere = false; } } return mlDsaHere; };
function signature(env, text) { export function signature(env, text) {
if (env.signature === undefined || env.signature === null) return level('signature', 'ABSENT', 'the envelope carries no signature'); if (env.signature === undefined || env.signature === null) return level('signature', 'ABSENT', 'the envelope carries no signature');
const s = env.signature; const s = env.signature;
if (typeof s !== 'object' || Array.isArray(s)) return level('signature', 'FAILED', 'the signature field is not an object {scheme, publicKey, signature}'); if (typeof s !== 'object' || Array.isArray(s)) return level('signature', 'FAILED', 'the signature field is not an object {scheme, publicKey, signature}');
@ -130,20 +145,41 @@ function signature(env, text) {
// ---------- level 4 ---------- // ---------- level 4 ----------
export async function finality(env, o) { export async function finality(env, o) {
if (!o.rpcGiven && !env.notarization) return level('finality', 'ABSENT', 'no notarization claimed and no --rpc given'); if (!o.rpcGiven && !env.notarization) return level('finality', 'ABSENT', 'no notarization claimed and no --rpc given');
// offline (2026-10-18): a claim is a claim; it is reported as not measured here, by name, never dropped and never passed
if (o.offline) return level('finality', 'UNMEASURED', `offline: the notarization this envelope claims (chain ${(env.notarization && env.notarization.chainId) || o.chain}) was not checked; this level needs the chain: run the reference verifier with the network (node verify-proof.mjs <file>)`);
const rpc = async (method, params) => { const rpc = async (method, params) => {
const r = await fetch(o.rpc, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'aere-verify-proof/1' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) }); const r = await fetch(o.rpc, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': 'aere-verify-proof/1' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
const j = await r.json(); if (j.error) throw new Error(method + ': ' + (j.error.message || JSON.stringify(j.error))); return j.result; const j = await r.json(); if (j.error) throw new Error(method + ': ' + (j.error.message || JSON.stringify(j.error))); return j.result;
}; };
// (0) 2026-10-27 (R9-S2): which chain the endpoint serves, read before anything else. A courier of ANOTHER chain says nothing about
// chain o.chain: the level is UNMEASURED, named, never FAILED. Until this date the question was not asked, verify-anchor.mjs measured
// the other chain's grid and reported a healthy chain as "not anchoring on a regular grid", and this level FAILED: the envelope
// INVALID, on an endpoint that had only been given for the wrong chain.
let served;
try { served = Number(BigInt(await rpc('eth_chainId', []))); }
catch (e) { return level('finality', 'UNMEASURED', `the endpoint ${o.rpc} did not say which chain it serves (eth_chainId: ${String(e && e.message).slice(0, 100)}); nothing about chain ${o.chain} was measured`); }
if (served !== o.chain) return level('finality', 'UNMEASURED', `the endpoint ${o.rpc} serves chain ${served}, not chain ${o.chain}: nothing about chain ${o.chain} was measured there (give --rpc an endpoint of chain ${o.chain})`);
// (a) the chain's most recent anchor, verified by verify-anchor.mjs, which reports the parent hash its seals signed // (a) the chain's most recent anchor, verified by verify-anchor.mjs, which reports the parent hash its seals signed
const report = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'aere-proof-')), 'anchor.json'); // the report lives in a temporary directory that is removed on every path once it is read (2026-10-21: until then each online
const va = await new Promise((ok) => { // finality check left one aere-proof-* directory behind; 296 of them after one stranger's pass)
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-proof-'));
const report = path.join(tmpDir, 'anchor.json');
let va, rep = null;
try {
va = await new Promise((ok) => {
const c = spawn(process.execPath, [path.join(HERE, 'verify-anchor.mjs')], { cwd: HERE, const c = spawn(process.execPath, [path.join(HERE, 'verify-anchor.mjs')], { cwd: HERE,
env: { ...process.env, RPC: o.rpc, CHAIN_ID: String(o.chain), MANIFESTS: o.manifests, ANCHORS: '1', PROPOSER_HEADERS: '2', ANCHOR_REPORT: report } }); env: { ...process.env, RPC: o.rpc, CHAIN_ID: String(o.chain), MANIFESTS: o.manifests, ANCHORS: '1', PROPOSER_HEADERS: '2', ANCHOR_REPORT: report } });
let out = ''; c.stdout.on('data', (d) => { out += d; }); c.stderr.on('data', (d) => { out += d; }); let out = ''; c.stdout.on('data', (d) => { out += d; }); c.stderr.on('data', (d) => { out += d; });
c.on('error', (e) => ok({ code: null, out: String(e.message) })); c.on('close', (code) => ok({ code, out })); c.on('error', (e) => ok({ code: null, out: String(e.message) })); c.on('close', (code) => ok({ code, out }));
}); });
let rep = null; try { rep = JSON.parse(fs.readFileSync(report, 'utf8')); } catch { rep = null; } try { rep = JSON.parse(fs.readFileSync(report, 'utf8')); } catch { rep = null; }
const tail = va.out.trim().split('\n').slice(-2).join(' | ').slice(0, 220); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); }
// the last two MESSAGES of verify-anchor.mjs: a line that starts with blanks continues the one before it (its FAIL lines wrap), so it is
// joined to it with a space, and only distinct messages are separated by ' | ' (2026-10-27, R9-S2: until then a wrapped sentence was
// cut in the middle, "which is not a | multiple of 32")
const mesaje = [];
for (const l of va.out.replace(/\r/g, '').split('\n')) { if (!l.trim()) continue; if (/^\s/.test(l) && mesaje.length) mesaje[mesaje.length - 1] += ' ' + l.trim(); else mesaje.push(l.trim()); }
const tail = mesaje.slice(-2).join(' | ').slice(0, 220);
// exit 1 is a verdict only with verify-anchor's own verdict line: Node also exits 1 when the tool cannot start (a dependency not // exit 1 is a verdict only with verify-anchor's own verdict line: Node also exits 1 when the tool cannot start (a dependency not
// installed: ERR_MODULE_NOT_FOUND), and that is not a failure of the chain (2026-09-29) // installed: ERR_MODULE_NOT_FOUND), and that is not a failure of the chain (2026-09-29)
const verdictLine = /^(FAIL:|PROBLEMS:)/m.test(va.out); const verdictLine = /^(FAIL:|PROBLEMS:)/m.test(va.out);
@ -192,6 +228,65 @@ export async function finality(env, o) {
} }
// ---------- main ---------- // ---------- main ----------
// ---------- level 5: the Bitcoin witness (martor-notar-verifica.mjs, one judge, never a second copy of it here) ----------
export const FORMAT_MARTOR = 'aere-notar-martor-1';
async function modulMartor() {
const candidati = [process.env.AERE_MARTOR_VERIFICA, path.join(HERE, 'martor-notar-verifica.mjs'), path.join(HERE, '..', 'tools', 'aere-quantum-shield', 'martor-notar-verifica.mjs')].filter(Boolean);
for (const c of candidati) { if (!fs.existsSync(c)) continue; try { const m = await import(pathToFileURL(c).href); if (typeof m.verificaMartor === 'function') return { m, cale: c }; } catch (e) { return { eroare: `${c}: ${String(e.message).slice(0, 120)}` }; } }
return { lipsa: candidati };
}
export async function witness(w, statementHash, o) {
const mod = await modulMartor();
if (mod.eroare) return level('witness', 'UNMEASURED', 'the Bitcoin witness judge does not load here: ' + mod.eroare);
if (!mod.m) return level('witness', 'UNMEASURED', `the Bitcoin witness judge (martor-notar-verifica.mjs) is not beside this verifier (looked in AERE_MARTOR_VERIFICA, ${HERE}, ../tools/aere-quantum-shield); the lot root can still be checked with the OpenTimestamps client: ots verify -d <rootHash without 0x> <lot .ots> (martor-notar-dovada.mjs --ots-separat writes it)`);
let r; try { r = await mod.m.verificaMartor(w, { statementHash, live: !!o.live, btcAntet: o.btcAntet || null }); }
catch (e) { return level('witness', 'UNMEASURED', 'the Bitcoin witness judge stopped: ' + String(e && e.message).slice(0, 160)); }
return level('witness', r.state, (r.reason ? r.reason + ': ' : '') + r.detail, { reason: r.reason || null, bitcoin: r.bitcoin || null });
}
// ---------- the judgement, one function ----------
// 2026-10-18 (B12, the Proof line): the whole judgement moved out of main() into judge(), exported, so that the browser page
// (deploy/website/verify.html, built by construieste-verificatorul-browser.mjs) runs THIS code, bundled, and not a second copy of
// it that could diverge. main() is the command line around it and prints exactly what it printed before.
/** a standalone witness file (format aere-notar-martor-1) is not an envelope: it is judged on its own level, and, since it names a
* notarization, on finality for the hash it witnesses (the chain's certified state must hold that hash, or the witness covers nothing) */
export const witnessFile = (env) => !!(env && typeof env === 'object' && !Array.isArray(env) && env.format === FORMAT_MARTOR && env.leaf && typeof env.leaf === 'object' && typeof env.leaf.hash === 'string');
/** the options of one judgement. The chain: opt.chain if given; otherwise the chain the envelope's notarization declares, when it is a
* known chain; otherwise 2800. (Before 2026-09-27 an envelope notarized on 28001, checked without --chain, was looked up on 2800 and
* reported INVALID.) The notary stays the known contract of that chain (or opt.notary): an address written in the envelope is never
* trusted as the notary. `offline`: nothing leaves this machine; the levels that need the chain are reported UNMEASURED by name. */
export function options(env, opt = {}) {
const declared = env && typeof env === 'object' && (witnessFile(env) ? Number(env.chainId) : env.notarization && Number(env.notarization.chainId));
const chain = Number(opt.chain || (declared && NOTARY[declared] ? declared : 2800));
if (!NOTARY[chain]) throw new Error('unknown chain ' + chain + ' (2800 or 28001)');
if (opt.offline && (opt.rpc || opt.live)) throw new Error('--offline excludes --rpc and --live: offline, nothing is read from the chain or from Bitcoin');
return { chain, rpcGiven: !!opt.rpc, rpc: opt.rpc || RPC_DEFAULT[chain], notary: (opt.notary || NOTARY[chain]).toLowerCase(),
manifests: opt.manifests || MANIFESTS_DEFAULT[chain], live: !!opt.live, btcAntet: opt.btcAntet || null, offline: !!opt.offline };
}
/** every level present, the verdict, and the exit code main() would give: 0 all passed, 1 a level failed, 2 one could not be measured */
export async function judge(env, o, notJson = null) {
const martorSingur = witnessFile(env);
let levels;
if (martorSingur) {
levels = [await witness(env, env.leaf.hash, o)];
levels.push(await finality({ statementHash: String(env.leaf.hash), notarization: { chainId: o.chain, txHash: env.leaf.txHash } }, o));
} else {
const l = local(env, notJson);
levels = [...l.out];
if (l.text && env && typeof env === 'object') { levels.push(signature(env, l.text)); levels.push(await finality(env, o)); if (env.bitcoinWitness !== undefined) levels.push(await witness(env.bitcoinWitness, env.statementHash, o)); }
}
const failed = levels.some((x) => x.state === 'FAILED');
const unmeasured = levels.some((x) => x.state === 'UNMEASURED' || x.state === 'PENDING');
const verdict = failed ? 'INVALID' : unmeasured ? 'PARTIAL' : 'VALID';
const shown = martorSingur ? String(env.leaf.hash) : env && typeof env === 'object' && typeof env.statementHash === 'string' ? env.statementHash : null;
return { verdict, statementHash: shown, levels, exitCode: failed ? 1 : unmeasured ? 2 : 0 };
}
/** the text of a file, as an envelope: JSON (a leading byte order mark is skipped), or `notJson` naming why it is not */
export function parseEnvelope(raw) {
try { return { env: JSON.parse(raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw), notJson: null }; }
catch (e) { return { env: undefined, notJson: 'the file is not JSON (' + String(e.message).split('\n')[0].slice(0, 80) + ')' }; }
}
// 2026-10-02 (AIP-24, L1): the finality path and its helpers are exported so that verify-shield.mjs (tools/aere-quantum-shield) // 2026-10-02 (AIP-24, L1): the finality path and its helpers are exported so that verify-shield.mjs (tools/aere-quantum-shield)
// judges a period head H_n through the SAME function, on a live endpoint or on a captured package replayed locally, instead of a // judges a period head H_n through the SAME function, on a live endpoint or on a captured package replayed locally, instead of a
// second copy of this logic. Run as a program, this file behaves exactly as before; imported, it runs nothing. // second copy of this logic. Run as a program, this file behaves exactly as before; imported, it runs nothing.
@ -199,31 +294,20 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
async function main() { async function main() {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const opt = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : null; }; const opt = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : null; };
const file = args.find((a, i) => !a.startsWith('--') && !(i > 0 && args[i - 1].startsWith('--') && args[i - 1] !== '--json')); const FARA_VALOARE = new Set(['--json', '--live', '--offline']);
if (!file) { console.error('usage: node verify-proof.mjs <envelope.json> [--rpc <url>] [--chain 2800|28001] [--notary <address>] [--manifests <url|dir>] [--json]'); stop(2); } const file = args.find((a, i) => !a.startsWith('--') && !(i > 0 && args[i - 1].startsWith('--') && !FARA_VALOARE.has(args[i - 1])));
if (!file) { console.error('usage: node verify-proof.mjs <envelope.json> [--rpc <url>] [--chain 2800|28001] [--notary <address>] [--manifests <url|dir>] [--live] [--btc-antet <hex>] [--offline] [--json]'); stop(2); }
let raw; try { raw = fs.readFileSync(file, 'utf8'); } catch (e) { console.error('the envelope cannot be read: ' + e.message); stop(2); } let raw; try { raw = fs.readFileSync(file, 'utf8'); } catch (e) { console.error('the envelope cannot be read: ' + e.message); stop(2); }
// a file that opens and is not JSON is not an envelope: refused at the form level, by name (a leading byte order mark is skipped) // a file that opens and is not JSON is not an envelope: refused at the form level, by name
let env, notJson = null; try { env = JSON.parse(raw.charCodeAt(0) === 0xFEFF ? raw.slice(1) : raw); } catch (e) { env = undefined; notJson = 'the file is not JSON (' + String(e.message).split('\n')[0].slice(0, 80) + ')'; } const { env, notJson } = parseEnvelope(raw);
// The chain: --chain if given; otherwise the chain the envelope's notarization declares, when it is a known chain; otherwise 2800. let o; try { o = options(env, { chain: opt('--chain'), rpc: opt('--rpc'), notary: opt('--notary'), manifests: opt('--manifests'), live: args.includes('--live'), btcAntet: opt('--btc-antet'), offline: args.includes('--offline') }); }
// (Before 2026-09-27 an envelope notarized on 28001, checked without --chain, was looked up on 2800 and reported INVALID.) The catch (e) { console.error(e.message); stop(2); }
// notary stays the known contract of that chain (or --notary): an address written in the envelope is never trusted as the notary. const r = await judge(env, o, notJson);
const declared = env && typeof env === 'object' && env.notarization && Number(env.notarization.chainId); if (args.includes('--json')) console.log(JSON.stringify({ verdict: r.verdict, statementHash: r.statementHash, levels: r.levels }, null, 1));
const chain = Number(opt('--chain') || (declared && NOTARY[declared] ? declared : 2800));
if (!NOTARY[chain]) { console.error('unknown chain ' + chain + ' (2800 or 28001)'); stop(2); }
const o = { chain, rpcGiven: !!opt('--rpc'), rpc: opt('--rpc') || RPC_DEFAULT[chain], notary: (opt('--notary') || NOTARY[chain]).toLowerCase(),
manifests: opt('--manifests') || MANIFESTS_DEFAULT[chain] };
const l = local(env, notJson);
const levels = [...l.out];
if (l.text && env && typeof env === 'object') { levels.push(signature(env, l.text)); levels.push(await finality(env, o)); }
const failed = levels.some((x) => x.state === 'FAILED');
const unmeasured = levels.some((x) => x.state === 'UNMEASURED' || x.state === 'PENDING');
const verdict = failed ? 'INVALID' : unmeasured ? 'PARTIAL' : 'VALID';
const shown = env && typeof env === 'object' && typeof env.statementHash === 'string' ? env.statementHash : null;
if (args.includes('--json')) console.log(JSON.stringify({ verdict, statementHash: shown, levels }, null, 1));
else { else {
console.log(`AERE Proof Protocol - ${path.basename(file)}`); console.log(`AERE Proof Protocol - ${path.basename(file)}`);
for (const x of levels) console.log(` [${x.state.padEnd(10)}] ${x.level}: ${x.detail}`); for (const x of r.levels) console.log(` [${x.state.padEnd(10)}] ${x.level}: ${x.detail}`);
console.log(`VERDICT: ${verdict}`); console.log(`VERDICT: ${r.verdict}`);
} }
process.exitCode = failed ? 1 : unmeasured ? 2 : 0; process.exitCode = r.exitCode;
} }

View File

@ -8,7 +8,7 @@ e citita din campul `katSource` al fiecarui fisier, scris 2026-09-02.
| fisier | schema | sursa declarata in fisier | sha256 (16) | | fisier | schema | sursa declarata in fisier | sha256 (16) |
|---|---|---|---| |---|---|---|---|
| `falcon1024_kat0.json` | NIST Falcon-1024 (round-3) — official KAT vector 0 and 1 | falcon1024-KAT.rsp (Falcon round-3 NIST submission) | `5f430ea31548fde0` | | `falcon1024_kat0.json` | NIST Falcon-1024 (round-3), official KAT vector 0 and 1 | falcon1024-KAT.rsp (Falcon round-3 NIST submission) | `5f430ea31548fde0` |
| `falcon512_kat0.json` | NIST Falcon-512 (round-3), KAT oficial | fisierul nu poarta camp `katSource`; `README.md` (randul "official NIST Falcon KAT vector 0 for both parameter sets") il declara vectorul KAT 0 al trimiterii Falcon round-3, ca si perechea lui `falcon1024_kat0.json` | `5011efbb97f68027` | | `falcon512_kat0.json` | NIST Falcon-512 (round-3), KAT oficial | fisierul nu poarta camp `katSource`; `README.md` (randul "official NIST Falcon KAT vector 0 for both parameter sets") il declara vectorul KAT 0 al trimiterii Falcon round-3, ca si perechea lui `falcon1024_kat0.json` | `5011efbb97f68027` |
| `mldsa44-acvp-tg8.json` | ML-DSA-44 (Dilithium2), FIPS 204 | NIST ACVP ML-DSA-sigVer-FIPS204 test vectors | github.com/usnistgov/ACVP-Server | gen-val/json-files/ML-DSA-sigVer-FIPS204 | `d9850650abdf446c` | | `mldsa44-acvp-tg8.json` | ML-DSA-44 (Dilithium2), FIPS 204 | NIST ACVP ML-DSA-sigVer-FIPS204 test vectors | github.com/usnistgov/ACVP-Server | gen-val/json-files/ML-DSA-sigVer-FIPS204 | `d9850650abdf446c` |
| `sphincs-sha2-128s-acvp-tg31.json` | SLH-DSA-SHA2-128s (SPHINCS+-SHA2-128s-simple), FIPS 205 | NIST ACVP SLH-DSA-sigVer-FIPS205 test vectors | github.com/usnistgov/ACVP-Server | gen-val/json-files/SLH-DSA-sigVer-FIPS205 | `943811677f3a2549` | | `sphincs-sha2-128s-acvp-tg31.json` | SLH-DSA-SHA2-128s (SPHINCS+-SHA2-128s-simple), FIPS 205 | NIST ACVP SLH-DSA-sigVer-FIPS205 test vectors | github.com/usnistgov/ACVP-Server | gen-val/json-files/SLH-DSA-sigVer-FIPS205 | `943811677f3a2549` |