From f7930b2ff933fa9025bb6e9c535ac4c030a6332a Mon Sep 17 00:00:00 2001 From: Aere Network Date: Tue, 20 Oct 2026 08:12:28 +0300 Subject: [PATCH] validator/: the operator kit for becoming a validator of chain 2800 (keys generated on the operator's machine, a public candidacy file with proof of possession and a signed clock report, the corrected runbook); run-node.sh, RUN-A-NODE.md and SPEC.md: the follower configuration described as it is (the attach, validatorCount, forkBlock and seal-cap properties it carries are inert on a follower) --- .gitattributes | 1 + README.md | 4 + RUN-A-NODE.md | 35 +- SPEC.md | 10 +- run-node.sh | 22 +- validator/AereCandidacy.java | 498 +++++++++++++++++++++++++ validator/README.md | 600 +++++++++++++++++++++++++++++++ validator/kit-common.sh | 90 +++++ validator/validator-candidacy.sh | 145 ++++++++ validator/validator-keys.sh | 99 +++++ 10 files changed, 1488 insertions(+), 16 deletions(-) create mode 100644 validator/AereCandidacy.java create mode 100644 validator/README.md create mode 100644 validator/kit-common.sh create mode 100644 validator/validator-candidacy.sh create mode 100644 validator/validator-keys.sh diff --git a/.gitattributes b/.gitattributes index fd635d1..87adeae 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,4 @@ patches/*.patch -text vectors/** -text registries/*.json -text registries/*.properties -text +validator/** text eol=lf diff --git a/README.md b/README.md index 80773e3..64be1c2 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,10 @@ able to trust without checking. ## See also (2026-09-02) +- [`validator/`](validator/README.md) (added 2026-10-20): the operator kit for becoming a validator of chain 2800. + Every key is generated on the operator's machine (`validator-keys.sh`); `validator-candidacy.sh` writes the public + candidacy file (registry row, enode, proof of possession, signed clock report). Nothing private leaves the machine. + - [`DEVNET.md`](DEVNET.md): AERE on your machine in one `docker run`, the validators' own binary as a local chain. - Public testnet 28001: `https://testnet-rpc.aere.network`, faucet on `https://aere.network/testnet.html`, an x402 v2 facilitator at `https://testnet-rpc.aere.network/x402/` settling a test dollar with EIP-3009 (probed every 6 hours, diff --git a/RUN-A-NODE.md b/RUN-A-NODE.md index 58e9920..c9ac837 100644 --- a/RUN-A-NODE.md +++ b/RUN-A-NODE.md @@ -484,12 +484,17 @@ prefer a quiet log once you are past the early heights, restart the node without validator set is large enough for the scheduled threshold to arm, and the shouting stops because the control is no longer lowered. -This is the FOLLOWER subset, and the distinction is in the code under `anchor/`, not guessed. A -validator additionally passes `aere.falcon.key` (the signing key), the `aere.falcon.attach*` and -`aere.falcon.validatorCount` properties (these drive seal PRODUCTION only), and -`aere.falcon.forkBlock` (a per-block Falcon rule the shipped code retires at the anchor block, so it -is inert; leaving it unset keeps the non-blocking baseline). What is above is exactly what the -IMPORT path reads: the registries (`aere.falcon.manifest` loads only together with +This is the configuration of a node that FOLLOWS the chain. *Corrected 2026-10-20:* until this date +the paragraph said that a follower leaves out `aere.falcon.attach*`, `aere.falcon.validatorCount` and +`aere.falcon.forkBlock`, while the block above, derived since 2026-09-06 from the archive node, carries +them. They are inert on a follower, because they act only when a node PRODUCES seals (the per-block +Falcon rule armed by `aere.falcon.forkBlock` is retired by the shipped code at the anchor block), and so +is `aere.pq.anchor.maxSeals`, a cap on what a PROPOSER writes: the block carries the archive node's own +value, nine, while the validators run ten, and a follower never proposes. A VALIDATOR additionally passes +its two key files (`aere.falcon.key` and `aere.pq.hybrid.key.slh-dsa-sha2-128s`, both generated on the +operator's own machine, see "Becoming a validator" below), the four emission heights +(`aere.pq.hybrid.attachBlock` and the proposal, prepare and round-change attach heights), the validators' +seal cap, and does not set `aere.pq.anchor.minSealsCeiling`. What the IMPORT path reads: the registries (`aere.falcon.manifest` loads only together with `aere.falcon.anchor.address`, so those two are registry wiring here, not attach knobs; `aere.falcon.registry.history` carries every earlier registry the chain was signed against), the anchor-validation schedule (`aere.pq.*`), and the 1 Gwei base-fee floor live from block 10,141,734. @@ -550,6 +555,24 @@ and the option is not part of the follower configuration above [NOT MEASURED]. --- +## Becoming a validator: the operator kit (added 2026-10-20) + +The `validator/` directory of this repository is what an independent operator uses to become a +validator of chain 2800, on the operator's own machine and without anything from the Foundation but +public parameters. It needs the build that `./run-node.sh --no-start` makes (the key tools are classes of +that build, patch 0008) and a JDK 21: + +| file | what it does | +|---|---| +| `validator/validator-keys.sh` | generates the Falcon-512 and SLH-DSA-SHA2-128s keys with `PqRegistryV3Tool keygen` from your build, owner-only, never overwriting; uses the node key `data/key` (it is also the consensus key); prints only public keys and the post-quantum id | +| `validator/validator-candidacy.sh` | checks that your running node uses that node key, measures your clock against the chain head, writes your registry row with `PqRegistryV3Tool row`, signs a candidacy message with all three keys, and writes `aere-candidacy.json` (public data only); `--clock-only` writes the fresh signed clock report asked for on the day of the vote | +| `validator/AereCandidacy.java` | the signing helper both scripts call, run by the JDK's source launcher against your build's `lib/`; the two message formats are written in its header and in `validator/README.md` | +| `validator/README.md` | the operator runbook: keys, clock, admission step by step, the validator properties; the same text is served at `https://aere.network/validator/README.md` | + +Both scripts refuse a build made from another patch set, a key file readable by anyone but its owner, +and a node that runs with another node key than the one registered; and both check their own output for +any private value before they end. No key, private or public, of any validator is in this repository. + ## Start it Export `BESU_OPTS` as above first, then: diff --git a/SPEC.md b/SPEC.md index aa2c80f..c305f8c 100644 --- a/SPEC.md +++ b/SPEC.md @@ -684,7 +684,9 @@ Semantics of each parameter (source: `PqAnchorConfig.java`): than K (measured seal counts at K=3, N=7: 42 blocks with 4 seals, 36 with 5, 5 with 6; a Falcon-512 seal is 666 bytes) (source: `PqAnchorConfig.java`, `PROPERTY_MAX_SEALS` javadoc, measurement dated 2026-08-07). Real anchor blocks carry three to nine seals (source: - `RUN-A-NODE.md`). + `RUN-A-NODE.md`). *Note added 2026-10-20:* the cap in the block above is the archive node's own value + (nine); the validators run ten (the validator kit, `validator/README.md`). The cap only limits what a + PROPOSER writes, so on a follower it is inert. - `aere.pq.anchor.disable`: local emergency de-arm; both rules off on this node; per-block disarm announcement keeps shouting. Local on purpose: a halted chain cannot deliver an on-chain change. - `aere.pq.chainId`: enters the D and M pre-images (domain separation from the proving chain @@ -692,8 +694,10 @@ Semantics of each parameter (source: `PqAnchorConfig.java`): - `aere.falcon.manifest` + `aere.falcon.anchor.address`/`anchor.block`: head registry wiring; `aere.falcon.registry.history`: every earlier registry, never pruned (a from-genesis node verifies every block ever produced) (source: `registries/README.md`, `RUN-A-NODE.md`). -- `aere.falcon.forkBlock`: a validator-side legacy per-block rule; see section 8, D6. A follower - leaves it unset. +- `aere.falcon.forkBlock`: a validator-side legacy per-block rule; see section 8, D6. *Corrected + 2026-10-20:* this line said a follower leaves it unset; the block above, derived from the archive node, + carries it, and it is inert there because the shipped code retires the per-block rule at the anchor + block. - `aere.pq.checkpoint` (added to this list 2026-10-07; in the recipe since patch 0008 of 2026-10-01, AIP-22 stage 4 code): `:[,:...]`, read by `PqCheckpointRule`, an attached header rule of the QBFT rule set (`QbftBlockHeaderValidationRulesetFactory`, patch 0008), diff --git a/run-node.sh b/run-node.sh index 525ecb4..3b48dae 100644 --- a/run-node.sh +++ b/run-node.sh @@ -226,14 +226,22 @@ fi mkdir -p "$DATA_PATH" -# BESU_OPTS: the follower subset of chain 2800's post-quantum configuration. +# BESU_OPTS: chain 2800's post-quantum configuration for a FOLLOWER. # -# WHY THESE AND NOT THE VALIDATOR SET. A validator additionally sets, and a follower deliberately -# OMITS: aere.falcon.key (the signing key, not in this repo), aere.falcon.attachBlock / -# attachInterval / minAttachFutureMargin / validatorCount (these drive seal PRODUCTION only), and -# aere.falcon.forkBlock (a per-block Falcon rule that the shipped code retires at the anchor block, -# so it is inert; leaving it unset keeps the non-blocking baseline). What is below is exactly what -# the IMPORT (validation) path reads, verified against anchor/ source: +# WHAT THE BLOCK BELOW IS (corrected 2026-10-20: until then this comment said a follower omits the +# attach, validatorCount and forkBlock properties, while the block, derived since 2026-09-06 from the +# live archive node, carries them). The block is the archive node's own configuration, minus keys, +# regenerated by tooling and never written by hand. A few of its properties act only when a node +# PRODUCES seals, and are inert on a follower: aere.falcon.attachBlock, attachInterval, +# minAttachFutureMargin and validatorCount, aere.falcon.forkBlock (a per-block Falcon rule the shipped +# code retires at the anchor block), and aere.pq.anchor.maxSeals, a cap on what a PROPOSER writes: +# the value below is the archive node's own (9), the validators run 10, and a follower never proposes. +# A VALIDATOR additionally passes its two key files (aere.falcon.key and +# aere.pq.hybrid.key.slh-dsa-sha2-128s, generated on the operator's machine by +# validator/validator-keys.sh), the four emission heights (aere.pq.hybrid.attachBlock and the +# proposal, prepare and round-change attach heights), the validators' seal cap, and does NOT set the +# seal-threshold ceiling below: validator/README.md, section 7b. What the IMPORT (validation) path +# reads, verified against anchor/ source: # - registries: aere.falcon.manifest is the head registry; it loads only WITH # aere.falcon.anchor.address (the manifest source is pending until that on-chain anchor is # observed), so anchor.address / anchor.block are import-relevant here, not attach knobs. diff --git a/validator/AereCandidacy.java b/validator/AereCandidacy.java new file mode 100644 index 0000000..d1fe666 --- /dev/null +++ b/validator/AereCandidacy.java @@ -0,0 +1,498 @@ +/* + * AereCandidacy.java - the signing helper of the Aere Network validator kit (chain 2800). + * + * It runs on the CANDIDATE's machine, against the lib/ directory of the candidate's OWN build of the + * Aere fork (run-node.sh --no-start builds it from the published patch series), with the JDK's + * single-file source launcher: + * + * java -cp '/build/install/besu/lib/*' AereCandidacy.java [options] + * + * It uses the classes of that build and nothing else: the registry v3 forms (PqRegistryV3, patch 0008), + * the Falcon-512 and SLH-DSA-SHA2-128s schemes the node signs with, Besu's secp256k1 and Besu's own + * KeyPairUtil (the code path that creates data/key when a node starts for the first time). + * + * WHAT IT NEVER DOES: print, log, copy or transmit any private key, or any part of one. It reads the + * key files, signs, verifies its own signatures before writing anything, and writes PUBLIC data only. + * The shell scripts next to it check that again on their own output (validator-candidacy.sh). + * + * Commands: + * params --params prints "chainId bindHeight count index" + * node-key --data-path creates /key with Besu's KeyPairUtil if + * (and only if) it does not exist + * ids --node-key-file --falcon-key --slh-key prints the public identity (JSON) + * candidacy --params

--row --falcon-key --slh-key --node-key-file + * --enode-host --p2p-port --issued --clock-measured-at + * --clock-head --clock-samples --clock-dmin --clock-dmax --out + * clock-report --chain-id --node-key-file --enode-host --p2p-port + * --clock-measured-at --clock-head --clock-samples --clock-dmin + * --clock-dmax --out + * + * The two signed messages (also written out in validator/README.md, "The candidacy file", so that + * anyone can verify a candidacy without this file): + * + * candidacy = keccak256("AERE-CANDIDACY-1" || u64 chainId || u64 bindHeight || u32 count || u32 index + * || u32 len(issued) || issued (ASCII, YYYY-MM-DD) || id (20 bytes) || rowHash (32 bytes)) + * clock = keccak256("AERE-CLOCK-1" || u64 chainId || u64 measuredAt || u64 headNumber || u32 samples + * || i32 dMin || i32 dMax) + * + * signed by: candidacy -> the node key (secp256k1, 65 bytes r||s||v), the Falcon-512 key and the + * SLH-DSA-SHA2-128s key of the row; clock -> the node key. Integers are big-endian; i32 is two's + * complement. rowHash is the registry v3 row hash (AIP-22 section 3.2, PqRegistryV3.rowHash). The node + * key is bound through its ADDRESS: the secp256k1 signatures must recover to the row's ecdsa address, + * which is also its transport address, and the enode's public key must hash to that same address. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import org.hyperledger.besu.consensus.common.bft.FalconSealScheme; +import org.hyperledger.besu.consensus.common.bft.PqRegistryV3; +import org.hyperledger.besu.consensus.common.bft.SealScheme; +import org.hyperledger.besu.consensus.common.bft.SealSchemes; +import org.hyperledger.besu.crypto.Hash; +import org.hyperledger.besu.crypto.KeyPair; +import org.hyperledger.besu.crypto.KeyPairUtil; +import org.hyperledger.besu.crypto.SECPPublicKey; +import org.hyperledger.besu.crypto.SECPSignature; +import org.hyperledger.besu.crypto.SignatureAlgorithm; +import org.hyperledger.besu.crypto.SignatureAlgorithmFactory; +import org.hyperledger.besu.ethereum.core.Util; + +import java.io.IOException; +import java.io.StringReader; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.Set; +import java.util.TreeMap; +import java.util.regex.Pattern; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.tuweni.bytes.Bytes; +import org.apache.tuweni.bytes.Bytes32; +import org.bouncycastle.pqc.crypto.falcon.FalconParameters; +import org.bouncycastle.pqc.crypto.falcon.FalconPrivateKeyParameters; + +public class AereCandidacy { + + static final String CANDIDACY_DOMAIN = "AERE-CANDIDACY-1"; + static final String CLOCK_DOMAIN = "AERE-CLOCK-1"; + static final String CANDIDACY_FORMAT = "aere-validator-candidacy/1"; + static final String CLOCK_FORMAT = "aere-validator-clock/1"; + static final String PARAMS_FORMAT = "aere-admission-params/1"; + static final ObjectMapper M = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT); + static final SignatureAlgorithm EC = SignatureAlgorithmFactory.getInstance(); + static final Pattern HOST = Pattern.compile("^([A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?|\\[[0-9A-Fa-f:.]{2,45}\\])$"); + static final Pattern DATE = Pattern.compile("^20[0-9]{2}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$"); + + public static void main(final String[] args) throws IOException { + if (args.length == 0) { + die(2, "usage: AereCandidacy.java params|node-key|ids|candidacy|clock-report [options] (see the header)"); + } + final Map o = new HashMap<>(); + for (int i = 1; i < args.length; i++) { + if (!args[i].startsWith("--") || i + 1 >= args.length) { + die(2, "options are --name value pairs; bad argument: " + args[i]); + } + o.put(args[i].substring(2), args[++i]); + } + try { + switch (args[0]) { + case "params" -> { + final long[] p = params(o); + System.out.println(p[0] + " " + p[1] + " " + p[2] + " " + p[3]); + } + case "node-key" -> nodeKey(o); + case "ids" -> ids(o); + case "candidacy" -> candidacy(o); + case "clock-report" -> clockReport(o); + default -> die(2, "unknown command " + args[0]); + } + } catch (final IllegalArgumentException | IllegalStateException e) { + // messages here never carry key material: every one of them is written below by hand + die(1, "REFUSED: " + e.getMessage()); + } + } + + // ---- the admission parameters the Foundation sends (public) ---------------------------------- + static long[] params(final Map o) throws IOException { + final JsonNode p = M.readTree(Files.readString(Path.of(need(o, "params")), StandardCharsets.UTF_8)); + if (p == null || !p.isObject()) { + throw new IllegalArgumentException("the admission parameters are not a JSON object"); + } + final Set allowed = Set.of("format", "chainId", "bindHeight", "count", "index", "note"); + for (final Iterator it = p.fieldNames(); it.hasNext(); ) { + final String f = it.next(); + if (!allowed.contains(f)) { + throw new IllegalArgumentException("the admission parameters carry an unknown field: " + f); + } + } + if (!PARAMS_FORMAT.equals(p.path("format").asText())) { + throw new IllegalArgumentException("the admission parameters are not of format " + PARAMS_FORMAT); + } + final long chain = integral(p, "chainId"); + final long bind = integral(p, "bindHeight"); + final long count = integral(p, "count"); + final long index = integral(p, "index"); + if (chain <= 0 || bind <= 0 || count < 1 || count > 64 || index < 0 || index >= count) { + throw new IllegalArgumentException("the admission parameters are out of range (chainId, bindHeight > 0; 1 <= count <= 64; 0 <= index < count)"); + } + return new long[] {chain, bind, count, index}; + } + + // ---- the node key: Besu's own path, never a second implementation ----------------------------- + static void nodeKey(final Map o) { + final Path dir = Path.of(need(o, "data-path")); + final Path key = dir.resolve("key"); + if (Files.exists(key)) { + throw new IllegalStateException(key + " already exists; nothing was created (the node key is never replaced)"); + } + if (!Files.isDirectory(dir)) { + throw new IllegalStateException(dir + " is not a directory"); + } + final KeyPair kp = KeyPairUtil.loadKeyPair(dir); + final ObjectNode out = M.createObjectNode(); + out.put("created", key.toString()); + out.put("address", hx(Util.publicKeyToAddress(kp.getPublicKey()).getBytes().toArrayUnsafe())); + out.put("nodePublicKey", kp.getPublicKey().getEncodedBytes().toHexString()); + print(out); + } + + // ---- the public identity ------------------------------------------------------------------------ + static void ids(final Map o) throws IOException { + final KeyPair kp = nodeKeyPair(need(o, "node-key-file")); + final Falcon f = falcon(need(o, "falcon-key"), -1); + final Slh s = slh(need(o, "slh-key"), -1); + if (f.index != s.index) { + throw new IllegalArgumentException("the Falcon key file is for index " + f.index + ", the SLH-DSA key file for index " + s.index); + } + final ObjectNode out = M.createObjectNode(); + out.put("address", hx(Util.publicKeyToAddress(kp.getPublicKey()).getBytes().toArrayUnsafe())); + out.put("nodePublicKey", kp.getPublicKey().getEncodedBytes().toHexString()); + out.put("index", f.index); + out.put("id", hx(PqRegistryV3.idOf(f.std))); + out.put("falcon512PublicKeySha256", Hash.sha256(Bytes.wrap(f.std)).toHexString()); + out.put("slhDsaSha2128sPublicKey", hx(s.pk)); + print(out); + } + + // ---- the candidacy -------------------------------------------------------------------------------- + static void candidacy(final Map o) throws IOException { + final long[] p = params(o); + final long chain = p[0], bind = p[1]; + final int count = (int) p[2], index = (int) p[3]; + final String issued = need(o, "issued"); + if (!DATE.matcher(issued).matches()) { + throw new IllegalArgumentException("--issued must be a UTC date YYYY-MM-DD"); + } + final KeyPair kp = nodeKeyPair(need(o, "node-key-file")); + final byte[] addr = Util.publicKeyToAddress(kp.getPublicKey()).getBytes().toArrayUnsafe(); + final byte[] nodePub = kp.getPublicKey().getEncodedBytes().toArrayUnsafe(); + final Falcon f = falcon(need(o, "falcon-key"), index); + final Slh s = slh(need(o, "slh-key"), index); + final String enode = enode(nodePub, o); + final Path out = Path.of(need(o, "out")); + if (Files.exists(out)) { + throw new IllegalStateException(out + " exists; a candidacy file is never overwritten"); + } + + // the row, as PqRegistryV3Tool wrote it: it must be THIS node's keys, bound to THESE parameters + final JsonNode rf = M.readTree(Files.readString(Path.of(need(o, "row")), StandardCharsets.UTF_8)); + if (rf == null || rf.path("index").asInt(-1) != index || !rf.has("row")) { + throw new IllegalArgumentException("the row file is not a PqRegistryV3Tool row for index " + index); + } + final JsonNode row = rf.get("row"); + final byte[] id = hex(text(row, "id")); + if (!Arrays.equals(id, PqRegistryV3.idOf(f.std))) { + throw new IllegalArgumentException("the row's id is not the derivation of this Falcon key (a new id must be)"); + } + if (!Arrays.equals(hex(text(row.path("keys"), "falcon-512")), f.std) + || !Arrays.equals(hex(text(row.path("keys"), "slh-dsa-sha2-128s")), s.pk)) { + throw new IllegalArgumentException("the row carries other public keys than the key files given"); + } + if (row.has("rotation")) { + throw new IllegalArgumentException("the row carries a rotation; a new validator's row never does"); + } + final byte[] transport = hex(text(row.path("transport"), "secp256k1Address")); + final byte[] ecAddr = hex(text(row.path("ecdsa"), "addr")); + if (!Arrays.equals(transport, addr) || !Arrays.equals(ecAddr, addr)) { + throw new IllegalArgumentException("the row's transport and ecdsa addresses must both be the address of the NODE key" + + " (the consensus key is the node key; a validator whose node key differs receives no consensus message)"); + } + final byte[] falconPop = hex(text(row.path("pop"), "falcon-512")); + final byte[] slhPop = hex(text(row.path("pop"), "slh-dsa-sha2-128s")); + final TreeMap keys = new TreeMap<>(); + keys.put(SealSchemes.FALCON_512.wireId() & 0xff, new PqRegistryV3.Key(SealSchemes.FALCON_512, f.std, falconPop)); + keys.put(SealSchemes.SLH_DSA_128S.wireId() & 0xff, new PqRegistryV3.Key(SealSchemes.SLH_DSA_128S, s.pk, slhPop)); + final Bytes32 rowHash = + PqRegistryV3.rowHash(chain, bind, count, new PqRegistryV3.Row(index, id, keys, transport, Map.of(), null, null)); + // the row's own proofs, checked again here before this node signs anything about it + if (!SealSchemes.FALCON_512.verifyRaw(f.raw896, + PqRegistryV3.popMessage(chain, bind, count, index, id, SealSchemes.FALCON_512.wireId(), f.std).toArrayUnsafe(), falconPop) + || !SealSchemes.SLH_DSA_128S.verifyRaw(s.pk, + PqRegistryV3.popMessage(chain, bind, count, index, id, SealSchemes.SLH_DSA_128S.wireId(), s.pk).toArrayUnsafe(), slhPop)) { + throw new IllegalStateException("a proof of possession in the row does not verify for these parameters"); + } + final SECPSignature claim = EC.decodeSignature(Bytes.wrap(hex(text(row.path("ecdsa"), "claim")))); + final Optional claimer = EC.recoverPublicKeyFromSignature(PqRegistryV3.claimDigest(chain, rowHash), claim); + if (claimer.isEmpty() || !Arrays.equals(Util.publicKeyToAddress(claimer.get()).getBytes().toArrayUnsafe(), addr)) { + throw new IllegalStateException("the row's ecdsa claim does not recover to the node key's address"); + } + + // the candidacy proof: three signatures over a message bound to the chain, the epoch, the date and the row + final Bytes32 msg = candidacyMessage(chain, bind, count, index, issued, id, rowHash); + final SECPSignature es = EC.sign(msg, kp); + final byte[] fs = ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(f.sk, msg.toArrayUnsafe()) + .orElseThrow(() -> new IllegalStateException("the Falcon key did not sign")); + final byte[] ss = SealSchemes.SLH_DSA_128S.sign(s.sk, msg.toArrayUnsafe()) + .orElseThrow(() -> new IllegalStateException("the SLH-DSA key did not sign")); + selfCheck(msg, es, nodePub, f, fs, s, ss); + final ObjectNode clock = clock(o, chain, nodePub, kp); + + final ObjectNode c = M.createObjectNode(); + c.put("format", CANDIDACY_FORMAT); + c.put("chainId", chain); + c.put("bindHeight", bind); + c.put("count", count); + c.put("index", index); + c.put("issued", issued); + c.put("enode", enode); + c.put("address", hx(addr)); + c.put("id", hx(id)); + c.set("row", row); + c.put("rowHash", rowHash.toHexString()); + final ObjectNode pr = c.putObject("proof"); + pr.put("domain", CANDIDACY_DOMAIN); + pr.put("message", msg.toHexString()); + pr.put("secp256k1", es.encodedBytes().toHexString()); + pr.put("falcon-512", hx(fs)); + pr.put("slh-dsa-sha2-128s", hx(ss)); + c.set("clock", clock); + Files.writeString(out, M.writeValueAsString(c) + "\n", StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); + final ObjectNode sum = M.createObjectNode(); + sum.put("written", out.toString()); + sum.put("enode", enode); + sum.put("address", hx(addr)); + sum.put("id", hx(id)); + sum.put("rowHash", rowHash.toHexString()); + print(sum); + } + + static void clockReport(final Map o) throws IOException { + final long chain = Long.parseLong(need(o, "chain-id")); + final KeyPair kp = nodeKeyPair(need(o, "node-key-file")); + final byte[] nodePub = kp.getPublicKey().getEncodedBytes().toArrayUnsafe(); + final Path out = Path.of(need(o, "out")); + if (Files.exists(out)) { + throw new IllegalStateException(out + " exists; a clock report is never overwritten"); + } + final ObjectNode c = M.createObjectNode(); + c.put("format", CLOCK_FORMAT); + c.put("chainId", chain); + c.put("enode", enode(nodePub, o)); + c.put("address", hx(Util.publicKeyToAddress(kp.getPublicKey()).getBytes().toArrayUnsafe())); + c.set("clock", clock(o, chain, nodePub, kp)); + Files.writeString(out, M.writeValueAsString(c) + "\n", StandardCharsets.UTF_8, StandardOpenOption.CREATE_NEW); + final ObjectNode sum = M.createObjectNode(); + sum.put("written", out.toString()); + sum.set("clock", c.get("clock")); + print(sum); + } + + // ---- messages ------------------------------------------------------------------------------------- + static Bytes32 candidacyMessage(final long chain, final long bind, final int count, final int index, final String issued, + final byte[] id, final Bytes32 rowHash) { + final byte[] d = CANDIDACY_DOMAIN.getBytes(StandardCharsets.US_ASCII); + final byte[] is = issued.getBytes(StandardCharsets.US_ASCII); + final ByteBuffer b = ByteBuffer.allocate(d.length + 8 + 8 + 4 + 4 + 4 + is.length + 20 + 32); + b.put(d).putLong(chain).putLong(bind).putInt(count).putInt(index).putInt(is.length).put(is).put(id) + .put(rowHash.toArrayUnsafe()); + return Hash.keccak256(Bytes.wrap(b.array())); + } + + static Bytes32 clockMessage(final long chain, final long measuredAt, final long head, final int samples, final int dMin, + final int dMax) { + final byte[] d = CLOCK_DOMAIN.getBytes(StandardCharsets.US_ASCII); + final ByteBuffer b = ByteBuffer.allocate(d.length + 8 + 8 + 8 + 4 + 4 + 4); + b.put(d).putLong(chain).putLong(measuredAt).putLong(head).putInt(samples).putInt(dMin).putInt(dMax); + return Hash.keccak256(Bytes.wrap(b.array())); + } + + static ObjectNode clock(final Map o, final long chain, final byte[] nodePub, final KeyPair kp) { + final long at = Long.parseLong(need(o, "clock-measured-at")); + final long head = Long.parseLong(need(o, "clock-head")); + final int samples = Integer.parseInt(need(o, "clock-samples")); + final int dMin = Integer.parseInt(need(o, "clock-dmin")); + final int dMax = Integer.parseInt(need(o, "clock-dmax")); + if (samples < 1 || dMin > dMax || head < 1 || at < 1) { + throw new IllegalArgumentException("the clock measurement is malformed"); + } + final Bytes32 cm = clockMessage(chain, at, head, samples, dMin, dMax); + final SECPSignature cs = EC.sign(cm, kp); + final Optional back = EC.recoverPublicKeyFromSignature(cm, cs); + if (back.isEmpty() || !Arrays.equals(back.get().getEncodedBytes().toArrayUnsafe(), nodePub)) { + throw new IllegalStateException("the clock signature does not recover to the node key; nothing written"); + } + final ObjectNode c = M.createObjectNode(); + c.put("domain", CLOCK_DOMAIN); + c.put("measuredAt", at); + c.put("headNumber", head); + c.put("samples", samples); + c.put("dMin", dMin); + c.put("dMax", dMax); + c.put("secp256k1", cs.encodedBytes().toHexString()); + return c; + } + + static void selfCheck(final Bytes32 msg, final SECPSignature es, final byte[] nodePub, final Falcon f, final byte[] fs, + final Slh s, final byte[] ss) { + final Optional back = EC.recoverPublicKeyFromSignature(msg, es); + if (back.isEmpty() || !Arrays.equals(back.get().getEncodedBytes().toArrayUnsafe(), nodePub)) { + throw new IllegalStateException("the secp256k1 signature does not recover to the node key; nothing written"); + } + if (!SealSchemes.FALCON_512.verifyRaw(f.raw896, msg.toArrayUnsafe(), fs)) { + throw new IllegalStateException("the Falcon-512 signature does not verify; nothing written"); + } + if (!SealSchemes.SLH_DSA_128S.verifyRaw(s.pk, msg.toArrayUnsafe(), ss)) { + throw new IllegalStateException("the SLH-DSA signature does not verify; nothing written"); + } + } + + // ---- key files (read, never printed) ----------------------------------------------------------- + record Falcon(int index, FalconPrivateKeyParameters sk, byte[] raw896, byte[] std) {} + + record Slh(int index, SealScheme.PrivateHandle sk, byte[] pk) {} + + static Falcon falcon(final String file, final int wantIndex) throws IOException { + final Properties fp = new Properties(); + fp.load(new StringReader(Files.readString(Path.of(file), StandardCharsets.UTF_8))); + final int index = Integer.parseInt(prop(fp, "index", "the Falcon key file")); + if (wantIndex >= 0 && index != wantIndex) { + throw new IllegalArgumentException("the Falcon key file is for index " + index + ", the admission parameters say " + wantIndex); + } + final byte[] h = hex(prop(fp, "pk", "the Falcon key file")); + if (h.length != PqRegistryV3.FALCON_512_PK_LENGTH - 1) { + throw new IllegalArgumentException("the Falcon key file's public key is not 896 bytes"); + } + final FalconPrivateKeyParameters sk = new FalconPrivateKeyParameters(FalconParameters.falcon_512, + hex(prop(fp, "f", "the Falcon key file")), hex(prop(fp, "g", "the Falcon key file")), hex(prop(fp, "F", "the Falcon key file")), h); + final byte[] std = new byte[PqRegistryV3.FALCON_512_PK_LENGTH]; + std[0] = PqRegistryV3.FALCON_512_PK_HEADER; + System.arraycopy(h, 0, std, 1, h.length); + // the private half must match the public half: sign a probe and verify it under the public key + final byte[] probe = Hash.keccak256(Bytes.wrap("AERE-KIT-PROBE".getBytes(StandardCharsets.US_ASCII))).toArrayUnsafe(); + final byte[] sig = ((FalconSealScheme) SealSchemes.FALCON_512).signWithParams(sk, probe) + .orElseThrow(() -> new IllegalArgumentException("the Falcon key does not sign")); + if (!SealSchemes.FALCON_512.verifyRaw(h, probe, sig)) { + throw new IllegalArgumentException("the Falcon key file's private and public halves do not match"); + } + return new Falcon(index, sk, h, std); + } + + static Slh slh(final String file, final int wantIndex) throws IOException { + final Properties sp = new Properties(); + sp.load(new StringReader(Files.readString(Path.of(file), StandardCharsets.UTF_8))); + final int index = Integer.parseInt(prop(sp, "index", "the SLH-DSA key file")); + if (wantIndex >= 0 && index != wantIndex) { + throw new IllegalArgumentException("the SLH-DSA key file is for index " + index + ", the admission parameters say " + wantIndex); + } + if (!"slh-dsa-sha2-128s".equals(sp.getProperty("alg", "").trim())) { + throw new IllegalArgumentException("the SLH-DSA key file does not name alg=slh-dsa-sha2-128s"); + } + final SealScheme.PrivateHandle sk = SealSchemes.SLH_DSA_128S.parsePrivateKey(hex(prop(sp, "sk", "the SLH-DSA key file"))) + .orElseThrow(() -> new IllegalArgumentException("the SLH-DSA private key does not parse")); + final byte[] pk = hex(prop(sp, "pk", "the SLH-DSA key file")); + final byte[] probe = Hash.keccak256(Bytes.wrap("AERE-KIT-PROBE".getBytes(StandardCharsets.US_ASCII))).toArrayUnsafe(); + final byte[] sig = SealSchemes.SLH_DSA_128S.sign(sk, probe).orElseThrow(() -> new IllegalArgumentException("the SLH-DSA key does not sign")); + if (!SealSchemes.SLH_DSA_128S.verifyRaw(pk, probe, sig)) { + throw new IllegalArgumentException("the SLH-DSA key file's private and public halves do not match"); + } + return new Slh(index, sk, pk); + } + + static KeyPair nodeKeyPair(final String file) throws IOException { + final String t = Files.readString(Path.of(file), StandardCharsets.UTF_8).trim(); + if (!t.matches("^(0x)?[0-9a-fA-F]{64}$")) { + throw new IllegalArgumentException("the node key file does not hold a 32-byte hex key"); + } + return EC.createKeyPair(EC.createPrivateKey(Bytes32.fromHexString(t))); + } + + static String enode(final byte[] nodePub, final Map o) { + final String host = need(o, "enode-host"); + final int port = Integer.parseInt(need(o, "p2p-port")); + if (!HOST.matcher(host).matches() || port < 1 || port > 65535) { + throw new IllegalArgumentException("--enode-host must be a host name or an IP address and --p2p-port a port number"); + } + return "enode://" + hx(nodePub).substring(2) + "@" + host + ":" + port; + } + + // ---- small helpers ----------------------------------------------------------------------------- + static String prop(final Properties p, final String k, final String what) { + final String v = p.getProperty(k); + if (v == null || v.isBlank()) { + throw new IllegalArgumentException(what + " has no '" + k + "' entry"); + } + return v.trim(); + } + + static long integral(final JsonNode p, final String k) { + final JsonNode v = p.get(k); + if (v == null || !v.isIntegralNumber()) { + throw new IllegalArgumentException("the admission parameters need the integer field " + k); + } + return v.asLong(); + } + + static String text(final JsonNode n, final String k) { + final JsonNode v = n == null ? null : n.get(k); + if (v == null || !v.isTextual()) { + throw new IllegalArgumentException("the row has no text field " + k); + } + return v.asText(); + } + + static String need(final Map o, final String k) { + final String v = o.get(k); + if (v == null || v.isBlank()) { + throw new IllegalArgumentException("missing --" + k); + } + return v; + } + + static byte[] hex(final String s) { + return Bytes.fromHexStringLenient(s.trim()).toArrayUnsafe(); + } + + static String hx(final byte[] b) { + final StringBuilder sb = new StringBuilder("0x"); + for (final byte x : b) { + sb.append(String.format(Locale.ROOT, "%02x", x & 0xff)); + } + return sb.toString(); + } + + static void print(final ObjectNode n) { + try { + System.out.println(M.writeValueAsString(n)); + } catch (final IOException e) { + die(2, "cannot write output"); + } + } + + static void die(final int code, final String msg) { + System.err.println(msg); + System.exit(code); + } +} diff --git a/validator/README.md b/validator/README.md new file mode 100644 index 0000000..b3342ed --- /dev/null +++ b/validator/README.md @@ -0,0 +1,600 @@ +# Run an AERE node, operator runbook + +This is the runbook for standing up an independent Hyperledger Besu node on the +AERE Network, chain ID **2800**, and applying to become one of the first +non-Foundation validators. + +Read it end to end before you start. It is written for someone who has run a +node before and is comfortable with Docker and a Linux server. + +> **Corrected 2026-10-20.** Until this date this runbook said that a validator's post-quantum keys +> are "issued at admission", listed a property (`aere.falcon.genesis`) that no running validator +> carries, and did not name the SLH-DSA key at all. All three were wrong. Every key of a validator is +> generated by its operator, on the operator's own machine, with the tools in the `validator/` +> directory of the `aere-node` repository; nothing private ever leaves that machine. Section 6 +> describes admission as it now works, step by step, and section 3b the clock requirement. + +--- + +## What AERE actually is today (read this first) + +AERE runs **Hyperledger Besu** with **QBFT** consensus and **sub-second blocks** (0.62 s on average +over the 100,000 blocks before 2026-10-15). +Consensus is a permissioned proof-of-authority BFT set, not proof-of-work and +not on-chain proof-of-stake. + +- The active validator set is **10 validators as read from the chain at 2026-09-29 07:55 UTC**, all Foundation-operated; f = 3, quorum 7-of-10 (as read from the chain at 2026-09-29 07:55 UTC). Read the live set with `qbft_getValidatorsByBlockNumber` and believe the chain over this line. +- QBFT tolerates `f = floor((n - 1) / 3)` faulty validators. At **n = 10, f = 3**: + the network tolerates three simultaneous Byzantine faults (commit quorum 7 of 10). + It is honest to say the set is small and single-operator today. +- The public path is **10 to 21** validators. This program exists + specifically to admit the **first independent (non-Foundation) operators**. +- Admission is a **governance vote of the existing validator set**, not an + automatic or permissionless process. There is **no staking, no bond, and no + slashing.** A validator that misbehaves or goes down for a prolonged period is + removed by the same governance vote that admitted it. Accountability is social + and on-chain, not economic. + +If you are looking for a stake-and-earn validator with slashing and unbonding, +that is not how AERE works. Do not trust older pages that describe self-staking, +slashing, unbonding periods, or APY tiers; they predate the current QBFT design. + +--- + +## What running this gives you + +A **full, syncing node** on chain 2800, built from the same published patch series the +validators run. A validator differs from a following node in four things, all of them yours to +hold and none of them issued by anyone else: + +1. its node key (`data/key`), which is also its consensus key: the address voted into the set; +2. a Falcon-512 key and an SLH-DSA-SHA2-128s key, generated on your machine (section 3); +3. a row in a post-quantum registry epoch that carries those public keys, signed by them (section 6); +4. the validator-only properties: the paths of the two key files and the heights from which the node + emits post-quantum seals (section 7b). + +So the sequence is: + +1. Stand up a following node with this runbook (or with `run-node.sh` in `aere-node`), and keep it + healthy. The Foundation observes it from outside for fourteen days before a vote is proposed. +2. Generate your keys and write your candidacy file on your own machine (sections 3 and 6). +3. The Foundation verifies the candidacy independently and puts your row into a new registry epoch, + which every node, yours included, loads before your admission. +4. Your node restarts as a validator and connects to every validator; then the existing validators + vote. After the vote your node proposes and seals blocks. No reinstall, no migration. + +--- + +## 1. Prerequisites + +**Software** + +- A Linux host (the live nodes run Docker on Linux). +- Docker and the Docker Compose plugin, or a build from source (`run-node.sh` in `aere-node` needs git + and a JDK 21). The key tools of section 3 need the same JDK 21 and a build from the published patch + series, which `./run-node.sh --no-start` produces. + +**Hardware** + +There is no published minimum-hardware spec sheet for AERE, so treat the +following as **guidance, not a documented requirement**. The Foundation confirms +final sizing on application. + +- The node is configured with a **4 GB JVM heap** (`-Xmx4g`), matching the live + validators. Give the host meaningful headroom above that. A reasonable + starting point is **4 vCPU, 8 GB RAM, NVMe SSD**, but confirm on application. +- **A CPU with SHA hardware instructions** (x86 SHA-NI, or the ARMv8 SHA2 extension). A validator signs + an SLH-DSA-SHA2-128s seal on the consensus thread at the parent of every anchor block; measured on + chain 2800 that costs 0.3 to 0.7 s per signature with the JDK digest, which uses those instructions + (AIP-22, table of constants). A slower signature delays the anchor round. +- **Local NVMe, not network-attached block storage.** The node writes its post-quantum seal store on the + consensus thread; on a network volume every such write adds milliseconds to each slot. +- Storage uses Besu **Bonsai** format. At sub-second blocks the trie-log data + grows faster than on a slow chain, so provision generous, growable NVMe disk + and monitor free space over time. Block headers are most of what a node stores. + +**Network / firewall** + +- **30303/tcp and 30303/udp**: open to the internet. This is P2P. Peers must be + able to reach you. +- **8545 (JSON-RPC)**: keep private. The compose binds it to `127.0.0.1` on the + host. Do not expose it to the public internet without an authenticating proxy + in front. +- **9545 (metrics)**: keep private, localhost only. + +**RPC policy for a validator.** A validator's JSON-RPC stays on localhost, always: it serves the +`qbft_*` methods, including `qbft_proposeValidatorVote`, and a public endpoint on a consensus node is a +denial-of-service surface on the very node that has to answer within a slot. If you want to offer +public reads, run a separate following node for them and keep the validator private. + +--- + +## 2. Get the config bundle + +Everything you need to start is in this directory +(`https://aere.network/validator/`): + +| File | What it is | Source | +| --- | --- | --- | +| `docker-compose.yml` | The node service, real QBFT flags | Published here | +| `config.toml` | Same config for bare-metal / from-source runs | Published here | +| `genesis.json` | Chain 2800 genesis (no private keys) | Published here | +| `static-nodes.json` | Current peer / bootnode enode list | **Provided by the Foundation on application** | +| Bootnode enode string | For `--bootnodes` | **Provided by the Foundation on application** | +| Canonical genesis hash | To verify your genesis | **Provided by the Foundation on application** | + +Download the published files into a working directory, for example +`/opt/aere`: + +```bash +mkdir -p /opt/aere && cd /opt/aere +curl -fSLO https://aere.network/validator/docker-compose.yml +curl -fSLO https://aere.network/validator/config.toml +curl -fSLO https://aere.network/validator/genesis.json +``` + +### The post-quantum registry files (added 2026-09-12) + +`docker-compose.yml` mounts two directories that the steps above did not create, and a node +started without them cannot judge an anchor certificate at all. Fetch them: + +```bash +mkdir -p /opt/aere/aere-pqc /opt/aere/pq && cd /opt/aere/aere-pqc +curl -fSLO https://aere.network/pq/manifests/manifest-13014000.json +curl -fSLO https://aere.network/pq/manifests/manifest-18082816.json +curl -fSLO https://aere.network/pq/manifests/registru-2800-v2-13600000.properties +cd /opt/aere/pq +curl -fSLO https://aere.network/pq/manifests/hibrid-10.properties +``` + +**Check what you downloaded, because a 200 is not proof of a file.** Until 2026-09-12 two of +these paths were not published, and the site answered every one of them with HTTP 200 and the +51 KB HTML page of the web app. `curl -fSLO` succeeds on that: exit code 0, a file on disk, the +wrong bytes. So look at the first character of each file, and refuse anything that starts with +`<`: + +```bash +for f in /opt/aere/aere-pqc/* /opt/aere/pq/*; do + head -c 1 "$f" | grep -q '<' && echo "NOT A REGISTRY FILE: $f" || echo "ok: $f" +done +``` + +The same four files ship inside the `aere-node` package, with a `SHA256SUMS` beside them, if you +prefer to take them from there and compare. + +**Added 2026-10-07: a fifth file, the stage-3 registry epoch.** Since block 20,836,228 (AIP-22 stage 3, +active on chain 2800 since 2026-09-30) the header carries post-quantum validator identities, and +`docker-compose.yml` now passes `aere.pq.identityBlock=20836228` with `aere.pq.registryV3=/opt/aere-pqc/epoca-v3-2800-20836228.json`. +Until 2026-10-07 this file did not list that option at all: a validator started from it would have stopped at +block 20,836,228. The epoch file is the one published as `registry-v3-20836228.json`; save it under the name +the option expects: + +```bash +cd /opt/aere/aere-pqc +curl -fSL -o epoca-v3-2800-20836228.json https://aere.network/pq/manifests/registry-v3-20836228.json +sha256sum epoca-v3-2800-20836228.json +``` + +Expected: `0212d8ff9e857f1c756b0004929e9ad4c7988341af4837d947e7b5263fb8c581` (194,122 bytes; measured on a +running validator of the fleet and on the served file on 2026-10-07). The option `aere.pq.registryV3.hashes` +pins the epoch's identity hash, so a node given the wrong file refuses to start rather than following the +chain with a wrong registry; the first-character check above applies to this file too. + +**Every later epoch is added, never swapped.** When an epoch that admits a new validator is announced, the +index at `https://aere.network/pq/manifests/index.json` lists it (`registry-v3-.json`, with its +sha256 and registry hash), and every node appends the file to `aere.pq.registryV3` and its +`:` pin to `aere.pq.registryV3.hashes`, keeping the earlier ones. The announcement gives the +exact values and the height by which every node must carry them. + +### Verify the genesis + +The `genesis.json` published here is the chain-2800 genesis. It contains **no +private keys**. The `comment` fields inside `alloc` are human labels that Besu +ignores, so they do not affect the genesis block hash. + +After your node's first start, confirm your node computed the **same genesis +hash** the Foundation gives you on application: + +```bash +curl -s -X POST http://127.0.0.1:8545 \ + -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","method":"eth_getBlockByNumber","params":["0x0",false],"id":1}' \ + | grep -o '"hash":"0x[0-9a-f]*"' +``` + +The value must equal the canonical chain-2800 genesis hash provided by the +Foundation on application. If it does not match, stop and contact the Foundation; +do not keep running against a divergent genesis. + +--- + +## 3. Your keys: generated on your machine, never sent anywhere + +A validator holds three private keys. You generate all three; the Foundation never sees, holds or +issues any of them. + +**The node key, `data/key` (secp256k1).** Besu creates it the first time the node starts. It is your +node's P2P identity **and your consensus key**: the address derived from it is the one voted into the +set, and Besu sends consensus messages only to peers whose node-key address is in the set. A validator +whose node key is not its validator key receives no consensus message at all. So never start the node +with a node key from another place than the data directory you register. + +Create the data directory, then start the node once so Besu writes the key: + +```bash +cd /opt/aere +mkdir -p data +docker compose up -d +``` + +Read your node's public info (enode and address) once it is up: + +```bash +curl -s -X POST http://127.0.0.1:8545 \ + -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","method":"net_enode","params":[],"id":1}' +``` + +**The post-quantum keys (Falcon-512 and SLH-DSA-SHA2-128s).** They are generated by +`PqRegistryV3Tool keygen`, a tool inside your own build of the published patch series (patch 0008), +through the kit in the `aere-node` repository: + +```bash +git clone https://git.aere.network/aere-network/aere-node && cd aere-node +./run-node.sh --no-start # builds the Aere fork from the published patches (JDK 21) +validator/validator-keys.sh --params admission-params.json --data-path +``` + +`admission-params.json` is a small public file the Foundation sends you (the chain id, the height and +size of the registry epoch that will carry your row, and your row index). The script refuses unless the +build comes from the bundle's patch series, writes the two key files owner-only (mode 600) into an +owner-only directory (default `~/.aere-validator/keys`, outside the repository on purpose), never +overwrites an existing key, prints only public keys and your post-quantum id, and checks its own output +for any private value before it ends. + +### Protect your keys + +- `data/key` **and** the two post-quantum key files **are** your validator identity. Back them up + together, somewhere secret and offline. Anyone with them can sign as your validator; losing them means + a new admission. +- **Never** `rm -rf data/`. That destroys the node key and, if you were already a + validator, forces re-admission. To wipe chain data while keeping the key: + + ```bash + find /opt/aere/data -mindepth 1 ! -name key -exec rm -rf {} + + ``` + +--- + +## 3b. The clock (added 2026-10-20) + +Chain 2800 rejects a block header whose timestamp is more than **one second** ahead of the node's own +clock (QBFT's `TimestampBoundedByFutureParameter(1)`), and at 0.6 s per block that rule bites at every +head: + +- a node whose clock is **behind** rejects the head blocks it is sent and disconnects the peer with + `BREACH_OF_PROTOCOL`, which reads like an attack and is a clock; +- a validator whose clock is **ahead** proposes blocks the others reject, and loses its slot. + +What to do: run NTP or chrony against good sources, and **measure against the chain head, not against a +flag**: a host that reports `NTPSynchronized=yes` was measured one full second behind on 2026-09-12. +The kit measures it for you, in the same terms the Foundation measures its own fleet (head timestamp +minus local clock, at most 1 s behind and at most 2 s ahead): + +```bash +validator/validator-candidacy.sh --clock-only --params admission-params.json --enode-host +``` + +On the day of the vote the Foundation asks for exactly this report, fresh (at most six hours old). It is +signed by your node key and holds nothing private. + +--- + +## 4. Add peers and finish syncing + +Until you have the Foundation's peer list, the node has nothing to connect to. +On application you receive: + +- a **bootnode enode** string, and +- a **`static-nodes.json`** file (belt-and-braces; Besu reads it from inside the + data directory). + +Wire them in: + +1. Put `static-nodes.json` next to the compose file. It is bind-mounted to + `data/static-nodes.json` by the compose (uncomment that volume line if you + commented it out). +2. Replace `BOOTNODE_ENODE_PROVIDED_ON_APPLICATION` in `docker-compose.yml` + (or `config.toml`) with the real enode string. +3. Restart: + + ```bash + docker compose up -d + ``` + +Watch it find peers and import blocks: + +```bash +docker compose logs -f aere-node +``` + +### The two traps that stop a fresh node cold + +- **The 5-peer wait.** Besu will not begin BFT block import until it has enough + peers, and it defaults to needing **5**. A small network never reaches 5 and + the node waits forever, never importing block 1. The compose already sets + **`--sync-min-peers=1`** to fix this. Keep it. +- **Gas price.** AERE nodes run **`--min-gas-price=0`**: the protocol's base-fee floor + (from block 10,141,734) sets the price of a transaction, not the node. Do not set a + nonzero min gas price of your own; you would drop normal AERE transactions. The + compose already sets it to 0. + +You do not need the public RPC fee-shim proxy. That proxy exists only for the +public wallet-facing RPC endpoint; a syncing full node talks to the chain +directly. + +--- + +## 5. Confirm the node is healthy + +```bash +# Current block height (should be climbing, then tracking the public head): +curl -s -X POST http://127.0.0.1:8545 -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' + +# Peer count (should be >= 1, ideally several): +curl -s -X POST http://127.0.0.1:8545 -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","method":"net_peerCount","params":[],"id":1}' + +# The current QBFT validator set (you will NOT be in it yet): +curl -s -X POST http://127.0.0.1:8545 -H 'Content-Type: application/json' \ + --data '{"jsonrpc":"2.0","method":"qbft_getValidatorsByBlockNumber","params":["latest"],"id":1}' +``` + +Compare your height against the public head at `https://aere.network/validators` +(the live validator-state dashboard). When your `eth_blockNumber` is tracking the +public head and you have steady peers, your node is ready to apply. + +--- + +## 6. Admission (the governance vote) + +This is the honest part: **admission is a decision of the existing validators, +not a transaction you can send yourself.** Here is the whole path, in order. + +1. **Observation.** Your following node runs for fourteen days while the Foundation measures it from + outside (that it follows the head, with the same block hashes as the fleet). You tell the Foundation + the read endpoint it may use for that. +2. **Admission parameters.** The Foundation sends `admission-params.json`: chain id 2800, the height of + the registry epoch that will carry your row, its size, and your row index. Public data. +3. **Your keys and your candidacy, on your machine.** `validator/validator-keys.sh` (section 3), then: + + ```bash + validator/validator-candidacy.sh --params admission-params.json --enode-host \ + --data-path + ``` + + With your node running, the script checks that the node runs with exactly the key in your data + directory (the enode it reports must match), measures your clock against the chain head (section + 3b), writes your registry row with `PqRegistryV3Tool row` from your build (your post-quantum id, + both public keys, a proof of possession by each, the link signed by your node key, and your node + key's address as the transport address), signs the candidacy message with all three keys, verifies + its own signatures, and checks that no private value appears in anything it wrote. Send the + resulting `aere-candidacy.json`. It holds public data only. +4. **Independent verification.** The Foundation verifies the candidacy with an implementation that + shares no code with the client (every signature, the proofs of possession, that your id is derived + from your Falcon key, that the enode's key is the row's address, that the date and the clock are + fresh and in margin), and refuses it otherwise, with the reason. +5. **The new registry epoch.** The existing validators re-sign their own rows for the new epoch (the same + keys; a proof of possession is bound to the epoch's height and size), your row is added as the last + one, and the epoch is checked by two independent loaders. The epoch is **published** at + `https://aere.network/pq/manifests/` in the same hour it takes effect, and every node, yours + included, appends its file and pin (section 2). +6. **Your node becomes a validator.** You restart it with the validator properties of section 7b and the + new epoch, connected to every validator (with the peer list the Foundation gives you, as in section 4), + and you send a fresh clock report (section 3b). +7. **The vote.** A majority of the current validators propose your post-quantum id with + `qbft_proposeValidatorVote(, true)`. Since block 20,836,228 (AIP-22 stage 3) the set and the + votes are on post-quantum ids, and a vote for an id that has no row in the registry in force is + invalid: that is why the epoch comes first. The vote is proposed only after every check of the steps + above passes, including that your node is connected to all validators. +8. **After the vote.** Your already-running, already-synced node detects that it is now in the set and + begins proposing and sealing blocks. The Foundation checks, from the chain and from its own nodes, + that you propose in turn, that your post-quantum commit seal is heard and verified by every node, + that your index appears in the anchor certificates, and that the chain's rhythm did not change. + +The Foundation decides whether an admission grows the set or whether a Foundation validator steps out +after you are in; for you the steps are the same. + +**Removal** is the same mechanism in reverse: +`qbft_proposeValidatorVote(, false)` by a majority. This is how a +misbehaving or long-offline validator is dropped. There is no slashing and no +stake to lose; the consequence is removal from the set. + +You can always read the live set yourself with +`qbft_getValidatorsByBlockNumber` (see step 5), so admission and removal are +publicly verifiable on-chain. + +### The candidacy file + +`aere-candidacy.json` (format `aere-validator-candidacy/1`) carries: `chainId`, `bindHeight`, `count`, +`index`, `issued` (UTC date), `enode`, `address`, `id`, `row` (the registry v3 row, AIP-22 section 3.2), +`rowHash`, `proof` and `clock`. The two signed messages, so that anyone can verify a candidacy without +our tools (integers big-endian, `i32` two's complement, `||` concatenation): + +``` +candidacy = keccak256("AERE-CANDIDACY-1" || u64 chainId || u64 bindHeight || u32 count || u32 index + || u32 len(issued) || issued || id (20 bytes) || rowHash (32 bytes)) +clock = keccak256("AERE-CLOCK-1" || u64 chainId || u64 measuredAt || u64 headNumber || u32 samples + || i32 dMin || i32 dMax) +``` + +`proof.secp256k1` (65 bytes, r || s || v) must recover to the row's `ecdsa.addr`, which must equal its +`transport` address and the address of the enode's public key; `proof.falcon-512` and +`proof.slh-dsa-sha2-128s` must verify under the row's two keys; `clock.secp256k1` must recover to the +same address. `dMin` and `dMax` are the smallest and largest "head timestamp minus local clock" over the +samples; the margin is `dMax <= 1` and `dMin >= -2`. + +--- + +## 7. How validators are paid + +Rewards are transparent and on-chain. **Measured 2026-10-15:** the beneficiary of +every block in a 40-block sample is the proposing validator's own identity address +(ten addresses, none with code), and blocks carry almost no transactions. The split +below is the design of the **AereCoinbaseSplitterV2** contract +(`0x8C1A48eFA57b66fEE743A00E3899c29ad3Fd27b4`); block fees do not pass through it +today (its balance was 0 that day), so read it as a deployed mechanism, not a +present-tense flow. It does a fixed basis-points split: + +- **Burn:** 3750 bps (**37.5%**) to the fee-burn vault. +- **Sink:** 1500 bps (**15.0%**) routed through AereSink (the sAERE flywheel). +- **Rebate to the validator:** the remainder, **47.5%** at the defaults. + +These are the current default splits. They are owner-adjustable within hard +caps written into the contract (burn at most 5000 bps, sink at most 3000 bps), +and any change to the sink destination is time-locked for 7 days. If the sink +is unset, its share is added to the validator rebate instead. Everything is +readable on-chain; nothing here is off-book. + +There is no staking yield, because there is no staking. A delegation UI tied to +the sAERE flywheel is on the roadmap but is not live today; do not assume any +delegation mechanics until the Foundation announces them. + +--- + +## 7b. If you are being admitted as a validator: the validator properties + +Everything above gets you a **following node**, and that is the normal case until the vote. A +validator passes, through `BESU_OPTS`, the whole line of `docker-compose.yml` (it is derived from a +running validator of the fleet and carries the validators' own seal cap) plus the properties below. +The Foundation confirms the exact values when your admission is scheduled. + +| property | what it is | +|---|---| +| `aere.falcon.key` | the path of YOUR Falcon-512 key file, written by `validator-keys.sh` | +| `aere.pq.hybrid.key.slh-dsa-sha2-128s` | the path of YOUR SLH-DSA-SHA2-128s key file, written by `validator-keys.sh` | +| `aere.pq.registryV3`, `aere.pq.registryV3.hashes` | every registry epoch and its pin, including the one that carries your row (section 2) | +| `aere.pq.hybrid.attachBlock`, `aere.pq.proposalPq.attachBlock`, `aere.pq.preparePq.attachBlock`, `aere.pq.roundChangePq.attachBlock` | the heights from which the node EMITS post-quantum seals; already in `docker-compose.yml` | + +Two things a validator must NOT carry: `aere.pq.anchor.minSealsCeiling` (a node syncing from genesis +with `run-node.sh` sets it to 1 to start inside the small genesis set; a validator starts into the full +set and must judge anchors at the scheduled threshold), and a node key from anywhere else than its data +directory (section 3). From block 20,836,228 the key a node signs with, and the index it signs under, are +looked up in the registry epoch in force by the PUBLIC key, and the client refuses to start a validator +registered in an epoch whose row does not match the key it holds (`AERE-PQC-KEY-NEXT-02`). + +**One path not yet measured, stated so you do not lose an evening to it.** A node that synced as a +follower and restarts as a validator passes the client's attach-height guard when it finds a recent +Falcon-sealed header; otherwise it refuses to start with `AERE-PQC-CFG-UNSAFE-05`, and the recovery +is `aere.falcon.attachBlockAcknowledgePast=` for one restart. Both behaviours were measured +on 2026-08-12 on the fleet of that time; for a node joining after block 20,836,228 they have not been +measured yet (the Foundation measures them on its rehearsal network before the first admission). If you +hit that refusal, send the log line; do not work around it alone: an attachment height that disagrees +with the fleet is a liveness incident, not a local setting. + +**One more measured fact, because it decides how you schedule your upgrades.** If +your node is a validator and it is still on a build without a rule the fleet has armed, it does +not fork and it does not accept anything wrong. But every time it is proposer at +an anchor height, the upgraded majority rejects its block and the round is lost. +Measured with two of seven nodes un-upgraded (2026-08): the chain head paused for about +twenty seconds across a hundred blocks. Being behind costs the network rhythm, not +safety. + +### 7c. Post-quantum seals on consensus messages (live since September 2026) + +Every 32nd block carries a post-quantum certificate under its hash. The blocks between anchors carry +no Falcon seal of their own, and their validity at import rests on ECDSA, so the measured depth a +fabricated segment could reach before an anchor stops it is **31 consecutive blocks**. + +Beyond the anchors, each of the four QBFT messages (proposal, prepare, commit, round-change) carries a +Falcon-512 seal of its author, each under its own domain. On chain 2800 the nine Besu-derived +validators refuse a message without a valid seal: COMMIT from block 17,250,000, PROPOSAL from +17,400,000, ROUND-CHANGE from 17,550,000 and PREPARE from 17,700,000. The tenth validator, which runs +the second client, refuses a COMMIT without one from block 18,131,633 and applies the other three +types from AIP-22 stage 4, whose height is not yet set. Each message has two switches, passed like +the anchor properties: + +| property | what it does | +|---|---| +| `aere.pq.Pq.attachBlock` | height from which **this node emits** a seal on that message | +| `aere.pq.Pq.forkBlock` | height from which **this node refuses** that message without a valid seal | + +where `` is one of `prepare`, `proposal`, `roundChange` (commit uses +`aere.pq.commitPq.forkBlock`). + +**Two things worth knowing, both measured:** + +- **Emission and enforcement are separate on purpose.** The fleet emitted for a while before + anything refused. If they were one switch, the first node to start emitting would also start + refusing, and that is a flag day. +- **A mistyped value refuses to start, it does not quietly disarm.** If you set one of these to + something that is not a plain block height, the client stops with a configuration error: a node + that boots disarmed because of a stray character looks exactly like a correctly configured one, + right up to the day it matters. + +**What this asks of you as a validator:** your post-quantum keys (Falcon-512, and SLH-DSA for the +hybrid anchor certificate), generated by you on your machine and carried in the registry epoch that +admits you, and the same emission and enforcement heights as the fleet, which the Foundation gives you. +A validator whose seals are refused does not fork and does not accept anything wrong: it loses its +turn as proposer, and the chain continues without it as long as a quorum of the others can seal. + +**What we do not claim.** Per-block finality on chain 2800 still rests on classical ECDSA, so AERE is +not a "fully post-quantum consensus" and we do not describe it as one. What you can verify yourself, +without taking our word for it, is the anchor certificate at every 32nd block, with the public +verifier. + +--- + +## 8. Day-two operations + +- **Updates:** the live network runs a pinned build of the **Aere fork** of Besu, + not stock upstream. Every new fleet build is published as a patch in `aere-node` before it is + required, with the height from which it is needed; rebuild with `./run-node.sh --no-start --rebuild` + and restart before that height. Do not move to `:latest` on your own. +- **Restarts:** tell the Foundation before a planned restart. A restarted validator starts with an empty + post-quantum seal store, and two validator restarts within minutes of each other can stall an anchor + round. A node restarting with its `data/` intact keeps its identity and does not re-hit the 5-peer + trap. +- **Emergencies:** the emergency settings of the anchor rules are applied uniformly to every node, never + to one; when the Foundation announces one, it applies to your node too, at the same time. +- **Backups:** back up `data/key` and the two post-quantum key files. Those are the irreplaceable files. +- **Monitoring:** scrape Prometheus metrics on `127.0.0.1:9545`. Track block + height versus the public head, peer count, disk free space (Bonsai growth) and the clock against the + chain head (section 3b). +- **Disk:** watch free space. At sub-second blocks storage grows steadily; plan + for a growable NVMe volume. + +--- + +## Quick reference + +| Item | Value | +| --- | --- | +| Network name | AERE Network | +| Chain ID / network ID | 2800 | +| Consensus | Hyperledger Besu, QBFT | +| Block time | sub-second since block 2,137,652 (0.62 s average over the 100,000 blocks before 2026-10-15) | +| Gas price | min-gas-price 0 on the node; the protocol base-fee floor sets the price | +| Storage format | Bonsai | +| Client | **Aere fork of Besu**, built from the public patch series of the `aere-node` bundle (0001 to 0010 as of 2026-10-14) on the named upstream commit. Stock upstream cannot follow chain 2800 past block 9,189,161. See RUN-A-NODE.md in `aere-node`. | +| Required JVM flags | the full `BESU_OPTS` line in `docker-compose.yml` (base-fee floor, post-quantum anchor, registry epochs, post-quantum identities, post-quantum transactions); a node missing one stops at the first height that needs it | +| Validator keys | node key `data/key` (also the consensus key), Falcon-512 and SLH-DSA-SHA2-128s; all generated by the operator (`validator/validator-keys.sh` in `aere-node`) | +| Candidacy | `validator/validator-candidacy.sh` in `aere-node`: registry row, enode, proof of possession, signed clock report | +| Clock | at most 1 s behind and 2 s ahead of the chain head, measured against the head | +| JVM heap | `-Xmx4g` | +| P2P port | 30303 tcp + udp (public) | +| RPC port | 8545 (keep private; on a validator, localhost only) | +| Metrics port | 9545 (keep private) | +| Active validators today | 10 (Foundation), as read from the chain on 2026-10-15 | +| Fault tolerance | f = 3, quorum 7-of-10 (as read from the chain at 2026-09-29 07:55 UTC) | +| Public path | 10 to 21 | +| Staking / bond / slashing | None | +| Admission | Governance vote of existing validators, after the registry epoch that carries your row | +| Reward contract | AereCoinbaseSplitterV2 `0x8C1A48eFA57b66fEE743A00E3899c29ad3Fd27b4` | +| Genesis hash | Provided by the Foundation on application | +| Bootnode enode | Provided by the Foundation on application | +| `static-nodes.json` | Provided by the Foundation on application | + +Live consensus explainer: `https://aere.network/consensus` +Live validator state: `https://aere.network/validators` diff --git a/validator/kit-common.sh b/validator/kit-common.sh new file mode 100644 index 0000000..bf13cbc --- /dev/null +++ b/validator/kit-common.sh @@ -0,0 +1,90 @@ +# kit-common.sh - shared by validator-keys.sh and validator-candidacy.sh (sourced, not run). +# +# Everything here runs on the candidate's machine. It needs bash, a JDK 21 (the same one run-node.sh +# requires), coreutils and curl. It holds no key and sends nothing anywhere. + +KIT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUNDLE_DIR="$(cd "$KIT_DIR/.." && pwd)" + +say() { printf '\n==> %s\n' "$*"; } +ok() { printf ' OK %s\n' "$*"; } +note() { printf ' %s\n' "$*"; } +die() { printf '\nREFUSED: %s\n' "$*" >&2; exit 1; } + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1 + else die "no sha256sum or shasum on PATH"; fi +} + +# mode of a file or directory, as three octal digits (GNU stat, then BSD stat) +mode_of() { stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null; } + +# A private file must be readable by its owner only (600 or 400); a key directory 700 or 500. +need_private_file() { + local m; m="$(mode_of "$1")" + case "$m" in 600|400) ok "$1 is owner-only ($m)";; *) die "$1 has mode ${m:-unknown}; a private key file must be owner-only. Run: chmod 600 '$1'";; esac +} +need_private_dir() { + local m; m="$(mode_of "$1")" + case "$m" in 700|500) ok "$1 is owner-only ($m)";; *) die "$1 has mode ${m:-unknown}; the key directory must be owner-only. Run: chmod 700 '$1'";; esac +} + +# kit_find_lib : sets LIB to the lib/ of a build made from the PUBLISHED patch series, or refuses. +# The registry tool and the signing helper must be the classes the fleet runs (class for class, published +# 2026-10-14 for patch 0010); a build from another patch set is refused exactly as run-node.sh refuses to +# reuse it. +kit_find_lib() { + local b="$1" + command -v java >/dev/null 2>&1 || die "java not found; JDK 21 is required (the same one run-node.sh uses)" + local v; v="$(java -version 2>&1 | head -1 | sed -E 's/.*version "([0-9]+).*/\1/')" + [ "$v" = "21" ] || die "JDK 21 is required, found: $(java -version 2>&1 | head -1)" + LIB="$b/build/install/besu/lib" + [ -d "$LIB" ] || die "no build at $LIB. Build the node first: ./run-node.sh --no-start (it builds from the published patch series)" + ls "$LIB"/besu-consensus-common-*.jar >/dev/null 2>&1 || die "$LIB has no besu-consensus-common jar; this is not a Besu build" + local want got p + # (every pipeline below that may legitimately find nothing ends in `|| true`: under pipefail an empty grep would otherwise end + # the script silently, and the emptiness is then refused with a message) + want="$(sed -n '/^PATCHES=(/,/^)/p' "$BUNDLE_DIR/run-node.sh" | grep -oE '"[0-9]{4}-[A-Za-z0-9._-]+\.patch"' | tr -d '"' \ + | while read -r p; do printf '%s %s\n' "$(sha256_of "$BUNDLE_DIR/patches/$p" < /dev/null)" "$p"; done || true)" + [ -n "$want" ] || die "cannot read the patch list from run-node.sh" + got="$(cat "$b/.aere-patchset" 2>/dev/null || true)" + [ "$got" = "$want" ] || die "the build at $b was not made from this bundle's patch series (or the marker .aere-patchset is missing). + Rebuild with: ./run-node.sh --no-start --rebuild" + ok "build from the published patch series: $LIB" + local u + u="$(java -Xmx256m -cp "$LIB/*" org.hyperledger.besu.consensus.common.bft.tools.PqRegistryV3Tool 2>&1 < /dev/null | head -2 || true)" + case "$u" in *usage*PqRegistryV3Tool*) ok "registry tool PqRegistryV3Tool present (patch 0008)";; + *) die "the build has no PqRegistryV3Tool (patch 0008); it cannot make a registry row";; esac +} + +kit_java() { java -Xmx512m -cp "$LIB/*" "$KIT_DIR/AereCandidacy.java" "$@"; } + +# The private values of a key set, one per line, for a FIXED-STRING search only (never printed, never +# written to disk: they go through a pipe into grep -f). Hex digits only, without 0x. +# NO EMPTY LINE ever comes out: an empty fixed-string pattern matches every line, and the check would refuse everything (or, used +# the other way round, find everything). +kit_private_values() { # falcon-key slh-key node-key + { + [ -f "$1" ] && grep -E '^(f|g|F)=' "$1" | cut -d= -f2- | tr -d '\r ' + [ -f "$2" ] && grep -E '^sk=' "$2" | cut -d= -f2- | tr -d '\r ' + [ -f "$3" ] && { tr -d '\r\n ' < "$3" | sed 's/^0x//'; echo; } + } | grep -E '^[0-9a-fA-F]{16,}$' || true +} +# kit_no_private_in ...: refuses if any private value appears in any of the files. +kit_no_private_in() { + local fk="$1" sk="$2" nk="$3"; shift 3 + local n + n="$(kit_private_values "$fk" "$sk" "$nk" | grep -c . || true)" + [ "$n" -ge 4 ] || die "the private-value check could not read the key files (got $n values, expected at least 4); nothing is trusted" + # control of the method: the search must FIND the values in the key file itself, or a zero below means nothing + local self; self="$(grep -c -i -F -f <(kit_private_values "$fk" "$sk" "$nk") "$fk" || true)" + [ "${self:-0}" -ge 3 ] || die "the private-value check does not find the values even in the key file (method blind); nothing is trusted" + local f hits + for f in "$@"; do + [ -f "$f" ] || continue + hits="$(grep -c -i -F -f <(kit_private_values "$fk" "$sk" "$nk") "$f" || true)" + [ "${hits:-0}" = 0 ] || die "a private key value appears in $f. Do not send or publish it; delete it and report this." + done + ok "no private key value in: $(printf '%s ' "$@" | sed 's#[^ ]*/##g')" +} diff --git a/validator/validator-candidacy.sh b/validator/validator-candidacy.sh new file mode 100644 index 0000000..c27d4af --- /dev/null +++ b/validator/validator-candidacy.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# +# validator-candidacy.sh - write the candidacy file a chain-2800 validator candidate sends to the +# Foundation: the registry row, the enode, a proof of possession of all three keys, and a signed +# measurement of this machine's clock against the chain head. PUBLIC data only. +# +# What it does, in order, on THIS machine: +# 1. checks that the build is from the published patch series and that the three keys are in place, +# owner-only (validator-keys.sh made the two post-quantum ones); +# 2. asks YOUR running node (its local RPC) for its enode, and refuses if the node runs with another +# key than /key: the consensus key must be the node key; +# 3. measures the clock: the chain head's timestamp against this machine's clock, several times. Chain +# 2800 rejects a header more than ONE second ahead of the node's clock +# (TimestampBoundedByFutureParameter(1)): a node whose clock is behind refuses the head blocks and +# disconnects its peers (BREACH_OF_PROTOCOL); a node whose clock is ahead makes the others refuse +# ITS blocks. Refuses outside [-2 s ahead, 1 s behind]; +# 4. writes the registry row with PqRegistryV3Tool row (your build, patch 0008): the id derived from +# your Falcon key, both public keys, a proof of possession by each, the link signed by the node key, +# and transport = the node key's address; +# 5. signs the candidacy message with all three keys (validator/README.md, "The candidacy file"), +# verifies its own signatures, and writes the candidacy file; +# 6. checks that no private key value appears in anything it wrote or printed. +# +# Usage: +# validator/validator-candidacy.sh --params --enode-host [options] +# validator/validator-candidacy.sh --clock-only --params --enode-host [options] +# (on the day of the vote: only a fresh, signed clock report, written to --out) +# +# --enode-host the public host or IP at which the validators reach your node (P2P) +# --p2p-port your P2P port. Default: 30303 +# --rpc your node's LOCAL JSON-RPC. Default: http://127.0.0.1:8545 +# --build-dir

Default: ./.besu-build next to run-node.sh +# --data-path Default: ./aere-data next to run-node.sh +# --keys-dir Default: $HOME/.aere-validator/keys +# --samples clock samples, two seconds apart. Default: 12 +# --out Default: ./aere-candidacy.json (./aere-clock-report.json with --clock-only) +# -h | --help +# +set -euo pipefail +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/kit-common.sh" + +PARAMS=""; HOSTP=""; P2P=30303; RPC="http://127.0.0.1:8545"; BUILD_DIR="$BUNDLE_DIR/.besu-build"; DATA_PATH="$BUNDLE_DIR/aere-data" +KEYS_DIR="${HOME}/.aere-validator/keys"; SAMPLES=12; OUT=""; CLOCK_ONLY=0 +usage() { awk 'NR>=2 && /^#/{sub(/^# ?/,"");print;next} NR>=2{exit}' "${BASH_SOURCE[0]}"; exit 0; } +while [ $# -gt 0 ]; do + case "$1" in + --params) PARAMS="${2:-}"; shift 2 ;; + --enode-host) HOSTP="${2:-}"; shift 2 ;; + --p2p-port) P2P="${2:-}"; shift 2 ;; + --rpc) RPC="${2:-}"; shift 2 ;; + --build-dir) BUILD_DIR="${2:-}"; shift 2 ;; + --data-path) DATA_PATH="${2:-}"; shift 2 ;; + --keys-dir) KEYS_DIR="${2:-}"; shift 2 ;; + --samples) SAMPLES="${2:-}"; shift 2 ;; + --out) OUT="${2:-}"; shift 2 ;; + --clock-only) CLOCK_ONLY=1; shift ;; + -h|--help) usage ;; + *) die "unknown argument: $1 (try --help)" ;; + esac +done +[ -n "$PARAMS" ] && [ -f "$PARAMS" ] || die "--params is required" +[ -n "$HOSTP" ] || die "--enode-host is required" +[ "$SAMPLES" -ge 3 ] 2>/dev/null || die "--samples must be a number >= 3" +[ -n "$OUT" ] || { [ "$CLOCK_ONLY" = 1 ] && OUT="./aere-clock-report.json" || OUT="./aere-candidacy.json"; } +[ -e "$OUT" ] && die "$OUT exists; it is never overwritten" +command -v curl >/dev/null 2>&1 || die "curl not found" + +say "1/6 The build and the keys" +kit_find_lib "$BUILD_DIR" +read -r CHAIN BIND COUNT INDEX < <(kit_java params --params "$PARAMS" 2>/dev/null) || die "the admission parameters do not parse" +[ -n "${INDEX:-}" ] || die "the admission parameters do not parse" +ok "chain $CHAIN, epoch bound at $BIND, $COUNT rows, your index $INDEX" +NK="$DATA_PATH/key"; FK="$KEYS_DIR/falcon-512.key"; SK="$KEYS_DIR/slh-dsa-sha2-128s.key" +[ -f "$NK" ] && [ -f "$FK" ] && [ -f "$SK" ] || die "keys missing ($NK, $FK, $SK); run validator/validator-keys.sh first" +need_private_dir "$KEYS_DIR"; need_private_file "$NK"; need_private_file "$FK"; need_private_file "$SK" +W="$(mktemp -d "$KEYS_DIR/.candidacy.XXXXXX")"; trap 'rm -rf "$W"' EXIT +kit_java ids --node-key-file "$NK" --falcon-key "$FK" --slh-key "$SK" > "$W/ids.json" 2> "$W/ids.log" || die "the keys do not form one identity (see $W/ids.log)" +NODEPUB="$(grep -o '"nodePublicKey" : "0x[0-9a-f]*"' "$W/ids.json" | grep -o '0x[0-9a-f]*' | sed 's/^0x//' || true)" +[ "${#NODEPUB}" = 128 ] || die "could not derive the node public key" + +rpc() { curl -s -m 10 -H 'content-type: application/json' --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$1\",\"params\":$2}" "$RPC" 2>/dev/null || true; } +say "2/6 Your node: the key it runs with" +R="$(rpc net_enode '[]')" +RUNPUB="$(printf '%s' "$R" | grep -oE 'enode://[0-9a-f]{128}' | head -1 | sed 's#enode://##' || true)" +[ -n "$RUNPUB" ] || die "your node does not answer net_enode at $RPC (start it with run-node.sh; the NET API is enabled there). A silent node is not a measurement" +[ "$RUNPUB" = "$NODEPUB" ] || die "your running node uses ANOTHER node key than $NK. The consensus key must be the node key: a validator whose node key differs receives no consensus message. Start the node with --data-path $DATA_PATH" +CID="$(rpc eth_chainId '[]' | grep -oE '"result":"0x[0-9a-f]+"' | grep -oE '0x[0-9a-f]+' || true)" +[ -n "$CID" ] && [ "$((CID))" = "$CHAIN" ] || die "your node is not on chain $CHAIN (eth_chainId says ${CID:-nothing})" +ok "the node at $RPC runs with the key in $NK (enode public key matches), chain $CHAIN" + +say "3/6 The clock, against the chain head ($SAMPLES samples)" +SY="$(rpc eth_syncing '[]')" +printf '%s' "$SY" | grep -q '"result":false' || die "your node is still syncing; measure the clock when it follows the head" +DMIN=""; DMAX=""; HEAD=0 +for i in $(seq 1 "$SAMPLES"); do + B="$(rpc eth_getBlockByNumber '["latest",false]')" + NOW="$(date +%s)" + TS="$(printf '%s' "$B" | grep -oE '"timestamp":"0x[0-9a-f]+"' | grep -oE '0x[0-9a-f]+' | head -1 || true)" + NUM="$(printf '%s' "$B" | grep -oE '"number":"0x[0-9a-f]+"' | grep -oE '0x[0-9a-f]+' | head -1 || true)" + [ -n "$TS" ] && [ -n "$NUM" ] || die "the node did not return the head block (sample $i); an empty answer is not a measurement" + D=$(( TS - NOW )); HEAD=$((NUM)) + if [ -z "$DMIN" ] || [ "$D" -lt "$DMIN" ]; then DMIN=$D; fi + if [ -z "$DMAX" ] || [ "$D" -gt "$DMAX" ]; then DMAX=$D; fi + if [ "$i" -lt "$SAMPLES" ]; then sleep 2; fi +done +AT="$(date +%s)" +note "head timestamp minus local clock: from $DMIN to $DMAX s over $SAMPLES samples (head $HEAD)" +[ "$DMAX" -le 1 ] || die "your clock is BEHIND the chain by up to $DMAX s. A node more than one second behind rejects the head blocks (TimestampBoundedByFutureParameter(1)) and disconnects its peers with BREACH_OF_PROTOCOL. Fix the clock (NTP or chrony), then measure again" +[ "$DMIN" -ge -2 ] || die "the head is $(( -DMIN )) s older than your clock: your clock is AHEAD, or your node is lagging. A validator whose clock runs ahead proposes blocks the others refuse. Fix the clock or let the node catch up, then measure again" +ok "clock within the margin (at most 1 s behind, at most 2 s ahead)" + +if [ "$CLOCK_ONLY" = 1 ]; then + say "4/4 The signed clock report" + kit_java clock-report --chain-id "$CHAIN" --node-key-file "$NK" --enode-host "$HOSTP" --p2p-port "$P2P" \ + --clock-measured-at "$AT" --clock-head "$HEAD" --clock-samples "$SAMPLES" --clock-dmin "$DMIN" --clock-dmax "$DMAX" \ + --out "$OUT" > "$W/out.json" 2> "$W/out.log" || die "the clock report was not written (see the message above)" + kit_no_private_in "$FK" "$SK" "$NK" "$OUT" "$W/out.json" "$W/out.log" + sed 's/^/ /' "$W/out.json" + printf '\n Send %s to the Foundation (it holds only public data and one signature by your node key).\n' "$OUT" + exit 0 +fi + +say "4/6 The registry row (PqRegistryV3Tool row, your build)" +SLHPUB="$(grep -E '^pk=' "$SK" | cut -d= -f2 | tr -d '\r ' || true)" +[ "${#SLHPUB}" = 64 ] || die "the SLH-DSA key file has no 32-byte public key" +java -Xmx512m -cp "$LIB/*" org.hyperledger.besu.consensus.common.bft.tools.PqRegistryV3Tool row --chain-id "$CHAIN" --bind "$BIND" \ + --count "$COUNT" --index "$INDEX" --falcon-key "$FK" --slh-key "$SK" --slh-pub "0x$SLHPUB" --ecdsa-key-file "$NK" --transport \ + > "$W/row.json" 2> "$W/row.log" < /dev/null || die "PqRegistryV3Tool row failed (see the tool's message in $W/row.log before it is removed: $(head -c 300 "$W/row.log" | tr '\n' ' '))" +ok "row written for index $INDEX" + +say "5/6 The candidacy proof and file" +kit_java candidacy --params "$PARAMS" --row "$W/row.json" --falcon-key "$FK" --slh-key "$SK" --node-key-file "$NK" \ + --enode-host "$HOSTP" --p2p-port "$P2P" --issued "$(date -u +%F)" --clock-measured-at "$AT" --clock-head "$HEAD" \ + --clock-samples "$SAMPLES" --clock-dmin "$DMIN" --clock-dmax "$DMAX" --out "$OUT" > "$W/out.json" 2> "$W/out.log" \ + || die "the candidacy was not written: $(grep -m1 REFUSED "$W/out.log" || head -c 300 "$W/out.log")" + +say "6/6 Nothing private in what was written" +kit_no_private_in "$FK" "$SK" "$NK" "$OUT" "$W/row.json" "$W/row.log" "$W/out.json" "$W/out.log" "$W/ids.json" "$W/ids.log" +sed 's/^/ /' "$W/out.json" +cat </key). It is also the consensus key: Besu sends consensus +# messages only to peers whose node-key address is in the validator set, so a validator whose +# node key is not its validator key receives no consensus message at all. The node creates this +# key the first time it starts (run-node.sh); this script uses it and does not replace it. +# 2. Falcon-512, the key behind every post-quantum seal (aere.falcon.key). +# 3. SLH-DSA-SHA2-128s, the second scheme of the hybrid anchor certificate +# (aere.pq.hybrid.key.slh-dsa-sha2-128s). +# +# 2 and 3 are generated by PqRegistryV3Tool keygen, the tool inside YOUR build of the published patch +# series (patch 0008): owner-only files, never overwritten, only the public keys printed. +# +# Nothing private is printed, logged or sent. The script checks its own output for that before it ends. +# +# Usage: +# validator/validator-keys.sh --params [options] +# +# --params the admission parameters the Foundation sends you (chainId, bindHeight, count, +# index): public, and the index is written into the key files +# --build-dir the build made by run-node.sh. Default: ./.besu-build next to run-node.sh +# --data-path the node's data directory (holds the node key). Default: ./aere-data next to run-node.sh +# --keys-dir where the two post-quantum keys are written (created owner-only). +# Default: $HOME/.aere-validator/keys (outside the repository, on purpose) +# --create-node-key create /key with Besu's own KeyPairUtil if it does not exist yet +# (the usual way is to start the node once; it creates the key itself) +# -h | --help +# +set -euo pipefail +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/kit-common.sh" + +PARAMS=""; BUILD_DIR="$BUNDLE_DIR/.besu-build"; DATA_PATH="$BUNDLE_DIR/aere-data"; KEYS_DIR="${HOME}/.aere-validator/keys"; CREATE=0 +usage() { awk 'NR>=2 && /^#/{sub(/^# ?/,"");print;next} NR>=2{exit}' "${BASH_SOURCE[0]}"; exit 0; } +while [ $# -gt 0 ]; do + case "$1" in + --params) PARAMS="${2:-}"; shift 2 ;; + --build-dir) BUILD_DIR="${2:-}"; shift 2 ;; + --data-path) DATA_PATH="${2:-}"; shift 2 ;; + --keys-dir) KEYS_DIR="${2:-}"; shift 2 ;; + --create-node-key) CREATE=1; shift ;; + -h|--help) usage ;; + *) die "unknown argument: $1 (try --help)" ;; + esac +done +[ -n "$PARAMS" ] && [ -f "$PARAMS" ] || die "--params is required (the Foundation sends it)" + +say "1/4 The build" +kit_find_lib "$BUILD_DIR" + +say "2/4 The admission parameters" +read -r CHAIN BIND COUNT INDEX < <(kit_java params --params "$PARAMS" 2>/dev/null) || die "the admission parameters do not parse (run: java -cp '$LIB/*' $KIT_DIR/AereCandidacy.java params --params $PARAMS)" +[ -n "${INDEX:-}" ] || die "the admission parameters do not parse" +ok "chain $CHAIN, registry epoch bound at block $BIND, $COUNT rows, your row index $INDEX" + +say "3/4 The node key (secp256k1, also the consensus key)" +NK="$DATA_PATH/key" +if [ ! -f "$NK" ]; then + [ "$CREATE" = 1 ] || die "$NK does not exist. Start your node once with run-node.sh (it creates the key), stop it, and run this again; or pass --create-node-key" + [ -d "$DATA_PATH" ] || (umask 077; mkdir -p "$DATA_PATH") + (umask 077; kit_java node-key --data-path "$DATA_PATH" > /dev/null 2>&1) || die "the node key could not be created with Besu's KeyPairUtil" + ok "created $NK with Besu's own KeyPairUtil" +fi +need_private_file "$NK" + +say "4/4 The post-quantum keys" +FK="$KEYS_DIR/falcon-512.key"; SK="$KEYS_DIR/slh-dsa-sha2-128s.key" +if [ ! -d "$KEYS_DIR" ]; then (umask 077; mkdir -p "$KEYS_DIR"); fi +need_private_dir "$KEYS_DIR" +if [ -f "$FK" ] && [ -f "$SK" ]; then + note "both keys already exist in $KEYS_DIR; they are NOT regenerated (a key is never replaced by this script)" +elif [ -f "$FK" ] || [ -f "$SK" ]; then + die "only one of the two keys exists in $KEYS_DIR; refusing to guess. Move the directory aside and run again" +else + (umask 077; java -Xmx512m -cp "$LIB/*" org.hyperledger.besu.consensus.common.bft.tools.PqRegistryV3Tool keygen \ + --index "$INDEX" --out-falcon "$FK" --out-slh "$SK" > "$KEYS_DIR/keygen-public.json" 2> "$KEYS_DIR/keygen.log" < /dev/null) \ + || die "PqRegistryV3Tool keygen failed (see $KEYS_DIR/keygen.log)" + ok "generated by PqRegistryV3Tool keygen in your build" +fi +need_private_file "$FK"; need_private_file "$SK" +IDS_FILE="$(mktemp "$KEYS_DIR/.ids.XXXXXX")"; trap 'rm -f "$IDS_FILE"' EXIT +kit_java ids --node-key-file "$NK" --falcon-key "$FK" --slh-key "$SK" > "$IDS_FILE" 2> "$KEYS_DIR/ids.log" \ + || die "the keys do not form one identity (see $KEYS_DIR/ids.log)" +grep -q "\"index\" : $INDEX\b" "$IDS_FILE" || die "the key files are not for index $INDEX (keys generated for another admission?)" +kit_no_private_in "$FK" "$SK" "$NK" "$IDS_FILE" "$KEYS_DIR/keygen-public.json" "$KEYS_DIR/keygen.log" "$KEYS_DIR/ids.log" + +say "Your validator identity (public; nothing private is shown)" +sed 's/^/ /' "$IDS_FILE" +cat < +EOF